lastSearchHadSystemFailure; } public function getLastSearchFailureReason(): ?string { return $this->lastSearchFailureReason; } /** * @return ShopProductResult[] */ public function search( string $originalPrompt, string $commerceIntent, string $commerceHistoryContext = '', ?CommerceReferenceContext $referenceContext = null ): array { $this->resetLastSearchFailure(); if (!$this->enabled) { $this->logger->info('Shop search skipped because commerce search is disabled', [ 'commerceIntent' => $commerceIntent, ]); return []; } $primaryQuery = $this->queryParser->parse( $originalPrompt, $commerceIntent, $commerceHistoryContext ); $focusMode = $this->determineFocusMode( originalPrompt: $originalPrompt, referenceContext: $referenceContext ); $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, 'focusMode' => $focusMode, 'hasCommerceHistoryContext' => $commerceHistoryContext !== '', 'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext), 'hasReferenceContext' => $referenceContext !== null, 'referenceProductName' => $referenceContext?->productName, 'referenceFocusTerms' => $referenceContext?->focusTerms, 'criteriaLimit' => $this->maxResults, ]); $referenceProbeResults = $this->probeReferenceContext( originalPrompt: $originalPrompt, commerceIntent: $commerceIntent, referenceContext: $referenceContext ); $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, 'focusMode' => $focusMode, 'hasReferenceContext' => $referenceContext !== null, 'referenceProductName' => $referenceContext?->productName, 'referenceFocusTerms' => $referenceContext?->focusTerms, ]); $rankedProducts = $this->executeSearch( $fallbackQuery, $commerceIntent, $originalPrompt, false ); } $finalProducts = $this->mergeRankedProductLists( $referenceProbeResults, $rankedProducts, $primaryQuery ); $finalProducts = $this->applyFocusGuardrails( products: $finalProducts, focusMode: $focusMode, originalPrompt: $originalPrompt, referenceContext: $referenceContext ); $finalProducts = $this->applyPriceFilters( products: $finalProducts, query: $primaryQuery ); $this->logger->info('Shop search finished', [ 'commerceIntent' => $commerceIntent, 'originalPrompt' => $originalPrompt, 'focusMode' => $focusMode, 'referenceProbeResultsCount' => count($referenceProbeResults), 'rankedProductsCount' => count($finalProducts), 'topProducts' => array_map( static fn(ShopProductResult $product): array => [ 'name' => $product->name, 'productNumber' => $product->productNumber, 'manufacturer' => $product->manufacturer, 'available' => $product->available, 'price' => $product->price, ], array_slice($finalProducts, 0, $this->shopConfig->getTopProductLogLimit()) ), ]); return $finalProducts; } /** * @return ShopProductResult[] */ private function probeReferenceContext( string $originalPrompt, string $commerceIntent, ?CommerceReferenceContext $referenceContext ): array { if ($referenceContext === null) { return []; } $probeQueries = $this->buildReferenceProbeQueries($referenceContext); if ($probeQueries === []) { return []; } $allResults = []; foreach ($probeQueries as $referenceSearchText) { $probeQuery = new CommerceSearchQuery( originalPrompt: $originalPrompt, normalizedPrompt: mb_strtolower($referenceSearchText, 'UTF-8'), searchText: $referenceSearchText, brand: $referenceContext->manufacturer, sizes: [], properties: [], priceMin: null, priceMax: null, intent: $commerceIntent, needsLlmFallback: false, ); $this->logger->info('Shop search reference probe', [ 'originalPrompt' => $originalPrompt, 'commerceIntent' => $commerceIntent, 'referenceSearchText' => $referenceSearchText, 'referenceProductName' => $referenceContext->productName, 'referenceProductNumber' => $referenceContext->productNumber, 'referenceFocusTerms' => $referenceContext->focusTerms, ]); $results = $this->executeSearch( $probeQuery, $commerceIntent, $originalPrompt, false ); if ($results !== []) { $allResults = array_merge($allResults, $results); } } if ($allResults === []) { return []; } $baseSearchText = $referenceContext->buildReferenceSearchText(); $baseQuery = new CommerceSearchQuery( originalPrompt: $originalPrompt, normalizedPrompt: mb_strtolower($baseSearchText, 'UTF-8'), searchText: $baseSearchText, brand: $referenceContext->manufacturer, sizes: [], properties: [], priceMin: null, priceMax: null, intent: $commerceIntent, needsLlmFallback: false, ); return $this->rerankProducts( $this->deduplicateProducts($allResults), $baseQuery ); } /** * @return string[] */ private function buildReferenceProbeQueries(CommerceReferenceContext $referenceContext): array { $queries = []; $baseProduct = trim($referenceContext->productName); $baseSearch = trim($referenceContext->buildReferenceSearchText()); if ($baseSearch !== '') { $queries[] = $baseSearch; } if ($baseProduct !== '') { $queries[] = $baseProduct; } foreach ($referenceContext->focusTerms as $focusTerm) { if (!is_string($focusTerm)) { continue; } $focusTerm = trim($focusTerm); if ($focusTerm === '') { continue; } if ($baseProduct !== '') { $queries[] = trim($baseProduct . ' ' . $focusTerm); } foreach ($this->expandFocusTermVariants($focusTerm) as $variant) { if ($variant === '') { continue; } if ($baseProduct !== '') { $queries[] = trim($baseProduct . ' ' . $variant); } if ($referenceContext->productNumber !== null && $referenceContext->productNumber !== '') { $queries[] = trim($baseProduct . ' ' . $referenceContext->productNumber . ' ' . $variant); } } } $queries = array_map( fn(string $value): string => $this->normalizeForMatching($value), $queries ); $queries = array_values(array_unique(array_filter( $queries, static fn(string $value): bool => $value !== '' ))); return $queries; } /** * @return string[] */ private function expandFocusTermVariants(string $focusTerm): array { $normalized = $this->normalizeForMatching($focusTerm); $variants = [$normalized]; $variantMap = $this->shopConfig->getAccessoryFocusVariantMap(); if (isset($variantMap[$normalized]) && is_array($variantMap[$normalized])) { $variants = array_merge($variants, $variantMap[$normalized]); } return array_values(array_unique(array_filter( array_map(fn(string $value): string => $this->normalizeForMatching($value), $variants), static fn(string $value): bool => $value !== '' ))); } /** * @return ShopProductResult[] */ private function executeSearch( CommerceSearchQuery $query, string $commerceIntent, string $originalPrompt, bool $usesHistoryContext ): array { $criteria = $this->criteriaBuilder->build($query, $this->maxResults); try { $response = $this->storeApiClient->searchProducts($criteria); return $this->mapAndLogSearchResponse( response: $response, query: $query, commerceIntent: $commerceIntent, originalPrompt: $originalPrompt, usesHistoryContext: $usesHistoryContext, usedSafeCriteria: false ); } catch (StoreApiException $e) { if ($e->isSafeCriteriaRetryRecommended()) { $safeResults = $this->retryWithSafeCriteria( query: $query, commerceIntent: $commerceIntent, originalPrompt: $originalPrompt, usesHistoryContext: $usesHistoryContext, previousException: $e ); if ($safeResults !== null) { return $safeResults; } } $this->recordFailedSearch($e); $this->logShopSearchFailure($query, $commerceIntent, $originalPrompt, $usesHistoryContext, $e); return []; } catch ( ClientExceptionInterface | RedirectionExceptionInterface | ServerExceptionInterface | TransportExceptionInterface | \RuntimeException $e ) { $this->recordFailedSearch($e); $this->logShopSearchFailure($query, $commerceIntent, $originalPrompt, $usesHistoryContext, $e); return []; } } /** * @return ShopProductResult[]|null */ private function retryWithSafeCriteria( CommerceSearchQuery $query, string $commerceIntent, string $originalPrompt, bool $usesHistoryContext, StoreApiException $previousException ): ?array { $this->logger->warning('Shop search retrying with safe criteria', [ 'commerceIntent' => $commerceIntent, 'originalPrompt' => $originalPrompt, 'normalizedPrompt' => $query->normalizedPrompt, 'searchText' => $query->searchText, 'usesHistoryContext' => $usesHistoryContext, 'previousStatusCode' => $previousException->getStatusCode(), 'previousUtf8Failure' => $previousException->isUtf8Failure(), 'previousExceptionMessage' => $previousException->getMessage(), ]); try { $safeCriteria = $this->criteriaBuilder->buildSafe($query, $this->maxResults); $response = $this->storeApiClient->searchProducts($safeCriteria); return $this->mapAndLogSearchResponse( response: $response, query: $query, commerceIntent: $commerceIntent, originalPrompt: $originalPrompt, usesHistoryContext: $usesHistoryContext, usedSafeCriteria: true ); } catch ( ClientExceptionInterface | RedirectionExceptionInterface | ServerExceptionInterface | TransportExceptionInterface | \RuntimeException $safeException ) { $this->recordFailedSearch($safeException); $this->logger->warning('Shop search safe criteria retry failed', [ 'commerceIntent' => $commerceIntent, 'originalPrompt' => $originalPrompt, 'normalizedPrompt' => $query->normalizedPrompt, 'searchText' => $query->searchText, 'usesHistoryContext' => $usesHistoryContext, 'exceptionClass' => $safeException::class, 'exceptionMessage' => $safeException->getMessage(), ]); return null; } } /** * @param array $response * @return ShopProductResult[] */ private function mapAndLogSearchResponse( array $response, CommerceSearchQuery $query, string $commerceIntent, string $originalPrompt, bool $usesHistoryContext, bool $usedSafeCriteria ): array { $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, 'usedSafeCriteria' => $usedSafeCriteria, 'rawElementsCount' => is_array($response['elements'] ?? null) ? count($response['elements']) : 0, 'mappedProductsCount' => count($mappedProducts), 'rankedProductsCount' => count($rankedProducts), ]); return $rankedProducts; } private function logShopSearchFailure( CommerceSearchQuery $query, string $commerceIntent, string $originalPrompt, bool $usesHistoryContext, \Throwable $e ): void { $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, 'systemFailure' => $this->lastSearchHadSystemFailure, 'failureReason' => $this->lastSearchFailureReason, 'exceptionClass' => $e::class, 'exceptionMessage' => $e->getMessage(), ]); } private function resetLastSearchFailure(): void { $this->lastSearchHadSystemFailure = false; $this->lastSearchFailureReason = null; } private function recordFailedSearch(\Throwable $e): void { $isSystemFailure = $e instanceof StoreApiException ? $e->isSystemFailure() : $e instanceof ServerExceptionInterface || $e instanceof TransportExceptionInterface; if (!$isSystemFailure) { return; } $this->lastSearchHadSystemFailure = true; $this->lastSearchFailureReason = $e->getMessage(); } /** * @param ShopProductResult[] $referenceProbeResults * @param ShopProductResult[] $rankedProducts * @return ShopProductResult[] */ private function mergeRankedProductLists( array $referenceProbeResults, array $rankedProducts, CommerceSearchQuery $primaryQuery ): array { if ($referenceProbeResults === [] && $rankedProducts === []) { return []; } $merged = $this->deduplicateProducts(array_merge($referenceProbeResults, $rankedProducts)); return $this->rerankProducts($merged, $primaryQuery); } private function determineFocusMode( string $originalPrompt, ?CommerceReferenceContext $referenceContext ): string { $normalizedPrompt = $this->normalizeForMatching($originalPrompt); if ($this->containsAnyKeyword($normalizedPrompt, $this->shopConfig->getDeviceFocusKeywords())) { return self::FOCUS_DEVICE; } if ($this->containsAnyKeyword($normalizedPrompt, $this->shopConfig->getAccessoryFocusKeywords())) { return self::FOCUS_ACCESSORY; } foreach ($referenceContext?->focusTerms ?? [] as $focusTerm) { if (!is_string($focusTerm)) { continue; } $normalizedFocusTerm = $this->normalizeForMatching($focusTerm); if ($normalizedFocusTerm === '') { continue; } if ($this->isAccessoryFocusToken($normalizedFocusTerm)) { return self::FOCUS_ACCESSORY; } } return self::FOCUS_NEUTRAL; } /** * @param ShopProductResult[] $products * @return ShopProductResult[] */ private function applyFocusGuardrails( array $products, string $focusMode, string $originalPrompt, ?CommerceReferenceContext $referenceContext ): array { if ($products === []) { return []; } return match ($focusMode) { self::FOCUS_ACCESSORY => $this->filterForAccessoryFocus( products: $products, originalPrompt: $originalPrompt, referenceContext: $referenceContext ), self::FOCUS_DEVICE => $this->filterForDeviceFocus( products: $products, originalPrompt: $originalPrompt ), default => $products, }; } /** * @param ShopProductResult[] $products * @return ShopProductResult[] */ private function filterForAccessoryFocus( array $products, string $originalPrompt, ?CommerceReferenceContext $referenceContext ): array { $normalizedPrompt = $this->normalizeForMatching($originalPrompt); $focusTerms = $this->extractAccessoryFocusTerms($normalizedPrompt, $referenceContext); if ($focusTerms === []) { return $products; } $accessoryMatches = []; $deviceMatches = []; $neutralMatches = []; foreach ($products as $product) { $isAccessoryLike = $this->isAccessoryLikeProduct($product); $isDeviceLike = $this->isDeviceLikeProduct($product); $matchesFocus = $this->productMatchesAnyFocusTerm($product, $focusTerms); if ($matchesFocus && $isAccessoryLike) { $accessoryMatches[] = $product; continue; } if ($matchesFocus) { $neutralMatches[] = $product; continue; } if ($isDeviceLike && !$isAccessoryLike) { $deviceMatches[] = $product; continue; } $neutralMatches[] = $product; } if ($accessoryMatches !== []) { $filtered = array_merge($accessoryMatches, $neutralMatches); $this->logger->info('Accessory focus guardrail kept focused accessory-like results', [ 'originalPrompt' => $originalPrompt, 'focusTerms' => $focusTerms, 'beforeCount' => count($products), 'afterCount' => count($filtered), ]); return $filtered; } if ($deviceMatches !== [] && $neutralMatches === []) { $this->logger->info('Accessory focus guardrail suppressed device-only results', [ 'originalPrompt' => $originalPrompt, 'focusTerms' => $focusTerms, 'suppressedDeviceCount' => count($deviceMatches), ]); return []; } return $neutralMatches !== [] ? $neutralMatches : $products; } /** * @param ShopProductResult[] $products * @return ShopProductResult[] */ private function filterForDeviceFocus(array $products, string $originalPrompt): array { $deviceMatches = []; $neutralMatches = []; $accessoryMatches = []; foreach ($products as $product) { $isAccessoryLike = $this->isAccessoryLikeProduct($product); $isDeviceLike = $this->isDeviceLikeProduct($product); if ($isDeviceLike && !$isAccessoryLike) { $deviceMatches[] = $product; continue; } if ($isAccessoryLike && !$isDeviceLike) { $accessoryMatches[] = $product; continue; } $neutralMatches[] = $product; } if ($deviceMatches !== []) { $filtered = array_merge($deviceMatches, $neutralMatches); $this->logger->info('Device focus guardrail kept device-like results', [ 'originalPrompt' => $originalPrompt, 'beforeCount' => count($products), 'afterCount' => count($filtered), ]); return $filtered; } if ($accessoryMatches !== [] && $neutralMatches === []) { $this->logger->info('Device focus guardrail suppressed accessory-only results', [ 'originalPrompt' => $originalPrompt, 'suppressedAccessoryCount' => count($accessoryMatches), ]); return []; } return $neutralMatches !== [] ? $neutralMatches : $products; } /** * @param ShopProductResult[] $products * @return ShopProductResult[] */ private function applyPriceFilters(array $products, CommerceSearchQuery $query): array { if ($products === []) { return []; } if ($query->priceMin === null && $query->priceMax === null) { return $products; } $filtered = []; foreach ($products as $product) { $price = $this->extractNumericPrice($product->price); if ($price === null) { continue; } if ($query->priceMin !== null && $price < $query->priceMin) { continue; } if ($query->priceMax !== null && $price > $query->priceMax) { continue; } $filtered[] = $product; } return $filtered; } /** * @return string[] */ private function extractAccessoryFocusTerms(string $normalizedPrompt, ?CommerceReferenceContext $referenceContext): array { $terms = []; foreach ($referenceContext?->focusTerms ?? [] as $focusTerm) { if (!is_string($focusTerm)) { continue; } $normalized = $this->normalizeForMatching($focusTerm); if ($normalized !== '' && $this->isAccessoryFocusToken($normalized)) { $terms[$normalized] = $normalized; } } foreach ($this->shopConfig->getAccessoryFocusKeywords() as $candidate) { $normalizedCandidate = $this->normalizeForMatching($candidate); if ($normalizedCandidate !== '' && str_contains($normalizedPrompt, $normalizedCandidate)) { $terms[$normalizedCandidate] = $normalizedCandidate; } } return array_values($terms); } private function isAccessoryFocusToken(string $token): bool { foreach ($this->shopConfig->getAccessoryFocusKeywords() as $candidate) { if ($token === $this->normalizeForMatching($candidate)) { return true; } } return false; } /** * @param string[] $focusTerms */ private function productMatchesAnyFocusTerm(ShopProductResult $product, array $focusTerms): bool { if ($focusTerms === []) { return false; } $corpus = $this->buildNormalizedProductCorpus($product); foreach ($focusTerms as $focusTerm) { if ($focusTerm === '') { continue; } if (str_contains($corpus, $focusTerm)) { return true; } } return false; } /** * @param string[] $keywords */ private function containsAnyKeyword(string $text, array $keywords): bool { foreach ($keywords as $keyword) { $normalizedKeyword = $this->normalizeForMatching($keyword); if ($normalizedKeyword !== '' && str_contains($text, $normalizedKeyword)) { return true; } } return false; } private function extractNumericPrice(?string $price): ?float { if ($price === null) { return null; } $normalized = str_replace( $this->shopConfig->getPriceNormalizationSearch(), $this->shopConfig->getPriceNormalizationReplace(), $price ); return is_numeric($normalized) ? (float) $normalized : null; } /** * @param array $response * @return ShopProductResult[] */ private function mapProducts(array $response): array { $elements = $response['elements'] ?? []; if (!is_array($elements)) { return []; } $results = []; foreach ($elements as $row) { if (!is_array($row)) { continue; } $relativeUrl = $this->extractUrl($row); $results[] = new ShopProductResult( id: (string) ($row['id'] ?? ''), name: trim((string) ($row['translated']['name'] ?? '')), productNumber: isset($row['productNumber']) ? (string) $row['productNumber'] : null, manufacturer: $this->extractManufacturer($row), price: $this->extractPrice($row), available: isset($row['available']) ? (bool) $row['available'] : null, url: $this->buildAbsoluteUrl($relativeUrl), highlights: $this->extractHighlights($row), description: $this->cleanUpDescription($row), productImage: $row['cover']['media']['thumbnails'][0]['url'] ?? $this->shopConfig->getMissingProductImagePlaceholder(), customFields: $this->getRelevantCustomFields($row['customFields'] ?? []) ); } $results = array_values(array_filter( $results, static fn(ShopProductResult $product): bool => $product->name !== '' )); return $this->deduplicateProducts($results); } /** * @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->normalizedPrompt : $query->originalPrompt ); $normalizedSearchText = $this->normalizeForMatching($query->searchText); $normalizedBrand = $this->normalizeForMatching((string) ($query->brand ?? '')); $normalizedSizes = array_values(array_filter(array_map( fn(mixed $size): string => $this->normalizeForMatching((string) $size), $query->sizes ))); $normalizedQuery = trim(implode(' ', array_filter([ $normalizedPrompt, $normalizedSearchText, $normalizedBrand, implode(' ', $normalizedSizes), ]))); $queryTokens = $this->tokenize($normalizedQuery); $queryNumberTokens = $this->extractNumberTokens($queryTokens); $normalizedProductName = $this->normalizeForMatching($product->name); $normalizedProductNumber = $this->normalizeForMatching((string) ($product->productNumber ?? '')); $normalizedManufacturer = $this->normalizeForMatching((string) ($product->manufacturer ?? '')); $normalizedProductCorpus = $this->buildNormalizedProductCorpus($product); $productNameTokens = $this->tokenize($normalizedProductName); $productNumberTokens = $this->tokenize($normalizedProductNumber); $productCorpusTokens = $this->tokenize($normalizedProductCorpus); $productNameNumberTokens = $this->extractNumberTokens($productNameTokens); $productNumberNumberTokens = $this->extractNumberTokens($productNumberTokens); $productCorpusNumberTokens = $this->extractNumberTokens($productCorpusTokens); if ($normalizedProductNumber !== '' && $this->containsWholePhrase($normalizedQuery, $normalizedProductNumber)) { $score += $this->shopConfig->getExactProductNumberPhraseScore(); } if ($normalizedProductName !== '' && $this->containsWholePhrase($normalizedQuery, $normalizedProductName)) { $score += $this->shopConfig->getExactProductNamePhraseScore(); } if ($normalizedBrand !== '') { if ($normalizedManufacturer !== '' && $normalizedManufacturer === $normalizedBrand) { $score += $this->shopConfig->getExactManufacturerMatchScore(); } elseif ($this->containsWholePhrase($normalizedProductName, $normalizedBrand)) { $score += $this->shopConfig->getBrandContainedInNameScore(); } } $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) * $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 === '') { continue; } if ( $this->containsWholePhrase($normalizedProductName, $normalizedSize) || $this->containsWholePhrase($normalizedProductNumber, $normalizedSize) || $this->containsWholePhrase($normalizedProductCorpus, $normalizedSize) ) { $score += $this->shopConfig->getSizeMatchScore(); } } $score += $this->scoreProductTypeMatch($product, $normalizedQuery); if ($product->available === true) { $score += $this->shopConfig->getAvailabilityBonusScore(); } return $score; } private function scoreProductTypeMatch(ShopProductResult $product, string $normalizedQuery): int { $score = 0; $isDeviceQuery = $this->isDeviceQuery($normalizedQuery); $isAccessoryQuery = $this->isAccessoryQuery($normalizedQuery); if (!$isDeviceQuery && !$isAccessoryQuery) { return 0; } $isAccessoryLikeProduct = $this->isAccessoryLikeProduct($product); $isDeviceLikeProduct = $this->isDeviceLikeProduct($product); if ($isDeviceQuery && !$isAccessoryQuery) { if ($isDeviceLikeProduct) { $score += $this->shopConfig->getDeviceQueryDeviceProductBonus(); } if ($isAccessoryLikeProduct) { $score -= $this->shopConfig->getDeviceQueryAccessoryPenalty(); } } if ($isAccessoryQuery) { if ($isAccessoryLikeProduct) { $score += $this->shopConfig->getAccessoryQueryAccessoryProductBonus(); } if ($isDeviceLikeProduct) { $score += $this->shopConfig->getAccessoryQueryDeviceProductBonus(); } } return $score; } private function isDeviceQuery(string $normalizedQuery): bool { foreach ($this->shopConfig->getDeviceQueryKeywords() as $keyword) { if (str_contains($normalizedQuery, $this->normalizeForMatching($keyword))) { return true; } } return false; } private function isAccessoryQuery(string $normalizedQuery): bool { foreach ($this->shopConfig->getAccessoryQueryKeywords() as $keyword) { if (str_contains($normalizedQuery, $this->normalizeForMatching($keyword))) { return true; } } return false; } private function isAccessoryLikeProduct(ShopProductResult $product): bool { $corpus = $this->buildNormalizedProductCorpus($product); foreach ($this->shopConfig->getAccessoryProductKeywords() as $keyword) { if (str_contains($corpus, $this->normalizeForMatching($keyword))) { return true; } } return false; } private function isDeviceLikeProduct(ShopProductResult $product): bool { $corpus = $this->buildNormalizedProductCorpus($product); foreach ($this->shopConfig->getDeviceProductKeywords() as $keyword) { if (str_contains($corpus, $this->normalizeForMatching($keyword))) { return true; } } return false; } private function buildNormalizedProductCorpus(ShopProductResult $product): string { return $this->normalizeForMatching(implode(' ', array_filter([ $product->name, $product->productNumber, $product->manufacturer, implode(' ', $product->highlights), $product->description, $product->customFields, $product->url, ]))); } /** * @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, 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($this->shopConfig->getMatchingCleanupPattern(), ' ', $value) ?? $value; $value = preg_replace($this->shopConfig->getWhitespaceCollapsePattern(), ' ', $value) ?? $value; return trim($value); } /** * @return string[] */ private function tokenize(string $value): array { if ($value === '') { return []; } return preg_split( $this->shopConfig->getTokenSplitPattern(), $value, -1, PREG_SPLIT_NO_EMPTY ) ?: []; } private function containsWholePhrase(string $normalizedText, string $normalizedPhrase): bool { if ($normalizedText === '' || $normalizedPhrase === '') { return false; } return str_contains( $this->shopConfig->wrapWithPaddingSpaces($normalizedText), $this->shopConfig->wrapWithPaddingSpaces($normalizedPhrase) ); } /** * @param array $customField */ private function getRelevantCustomFields(array $customField): string { $primary = (string) ($customField[$this->shopConfig->getPrimaryCustomFieldKey()] ?? ''); $secondary = (string) ($customField[$this->shopConfig->getSecondaryCustomFieldKey()] ?? ''); $useCases = (string) ($customField[$this->shopConfig->getUseCasesCustomFieldKey()] ?? ''); $languages = (string) ($customField[$this->shopConfig->getLanguagesCustomFieldKey()] ?? ''); $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))); } /** * @param array $description */ private function cleanUpDescription(array $description): string { if (!isset($description['translated']['description'])) { return ''; } $newDesc = strip_tags((string) ($description['translated']['description'])); $newDesc = html_entity_decode($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, $this->shopConfig->getDescriptionMaxLength()); } /** * @param array $row */ private function extractManufacturer(array $row): ?string { $manufacturer = $row['manufacturer'] ?? null; if (is_array($manufacturer) && isset($manufacturer['name']) && is_string($manufacturer['name'])) { $name = trim($manufacturer['name']); return $name !== '' ? $name : null; } return null; } /** * @param array $row */ private function extractPrice(array $row): ?string { $calculatedPrice = $row['calculatedPrice'] ?? null; if (!is_array($calculatedPrice)) { return null; } $candidates = [ $calculatedPrice['unitPrice'] ?? null, $calculatedPrice['totalPrice'] ?? null, $calculatedPrice['referencePrice'] ?? null, $calculatedPrice['listPrice'] ?? null, $calculatedPrice['regulationPrice'] ?? null, ]; foreach ($candidates as $candidate) { if (!is_numeric($candidate)) { continue; } $value = (float) $candidate; if ($value > 0.0) { return number_format( $value, $this->shopConfig->getPriceDecimals(), $this->shopConfig->getPriceDecimalSeparator(), $this->shopConfig->getPriceThousandsSeparator() ) . $this->shopConfig->getPriceSuffix(); } } return null; } /** * @param array $row */ private function extractUrl(array $row): ?string { $seoUrls = $row['seoUrls'] ?? null; if (!is_array($seoUrls) || $seoUrls === []) { return null; } foreach ($seoUrls as $seoUrl) { if (!is_array($seoUrl)) { continue; } $path = $seoUrl['seoPathInfo'] ?? null; if (is_string($path) && trim($path) !== '') { return $this->shopConfig->buildRelativeSeoUrl($path); } } return null; } private function buildAbsoluteUrl(?string $relativeUrl): ?string { if ($relativeUrl === null || trim($relativeUrl) === '') { return null; } return rtrim($this->baseUrl, '/') . '/' . ltrim($relativeUrl, '/'); } /** * @param array $row * @return string[] */ private function extractHighlights(array $row): array { $highlights = []; if (isset($row['available'])) { $highlights[] = (bool) $row['available'] ? $this->shopConfig->getAvailableHighlightLabel() : $this->shopConfig->getUnavailableHighlightLabel(); } if (isset($row['productNumber']) && is_string($row['productNumber']) && trim($row['productNumber']) !== '') { $highlights[] = $this->shopConfig->getProductNumberHighlightPrefix() . trim($row['productNumber']); } return array_values(array_unique($highlights)); } /** * @param ShopProductResult[] $products * @return ShopProductResult[] */ private function deduplicateProducts(array $products): array { $unique = []; $seen = []; foreach ($products as $product) { $key = mb_strtolower(trim(implode($this->shopConfig->getDeduplicationSeparator(), [ $product->id, $product->productNumber ?? '', $product->name, $product->url ?? '', ])), 'UTF-8'); if (isset($seen[$key])) { continue; } $seen[$key] = true; $unique[] = $product; } return $unique; } }