searchEngine = $searchEngine; $this->providerRegistry = $providerRegistry; $this->mediaDetector = $mediaDetector; $this->domainEngine = $domainEngine; $this->routingTable = $routingTable ?? new ProviderRoutingTable(); $this->productDataMerger = $productDataMerger ?? new ProductDataMerger(); $this->resultRanker = $resultRanker ?? new SearchResultRanker(); } public function search(SearchRequest $request): SearchResultCollection { $requestedMediaType = MediaType::normalize($request->getMediaType()); if ($requestedMediaType === MediaType::AUTO || $requestedMediaType === MediaType::UNKNOWN) { return $this->emptyCollection( $request->isIdentifierSearch() ? SearchType::IDENTIFIER : SearchType::TITLE, 'AUTO/Tutti is orchestrated exclusively by CascadeSearchOrchestrator.' ); } return $this->searchConcreteDomain($request, $requestedMediaType); } private function withAdditionalTrace(SearchResultCollection $result, array $trace): SearchResultCollection { return new SearchResultCollection( $result->getItems(), $result->getTotal(), $result->getSearchType(), $result->getProviderUsed(), $result->getProviderFallbackFrom(), $result->getProviderAttempts(), $result->getProviderResponseTimeMs(), array_merge($trace, $result->getProviderTrace()), $result->getRemoteTotal(), $result->getKnownQualifiedTotal(), $result->hasRemoteContinuation(), $result->getProviderContinuations() ); } private function searchConcreteDomain(SearchRequest $request, string $mediaType): SearchResultCollection { try { $domainResolution = $this->domainEngine->resolve($request->getQuery(), $mediaType); } catch (DiscoveryUnresolvedException $e) { return $this->emptyCollection(SearchType::IDENTIFIER, $e->getMessage()); } $searchIntent = $this->resolveSearchIntent($request, $domainResolution); $searchType = $this->resolveSearchType($request, $searchIntent, $domainResolution); $resolvedDomain = $domainResolution->getResolvedDomain(); $policyProviders = $searchType === SearchType::TITLE && $resolvedDomain === MediaType::JEWELRY ? RuntimeProviderCatalog::titleDiscoveryProviders($resolvedDomain) : $domainResolution->getProviderChain()->getProviders(); $resolvedProviders = $this->filterAvailableProviders( $policyProviders, $searchType, $resolvedDomain ); $hasExplicitPreferredProviders = $request->getPreferredProviders() !== []; $preferred = $hasExplicitPreferredProviders ? $this->filterAvailableProviders( $request->getPreferredProviders(), $searchType, $resolvedDomain, false, true ) : $resolvedProviders; $plannedProviders = $hasExplicitPreferredProviders ? $request->getPreferredProviders() : $policyProviders; $skippedProviderTrace = array_map(static fn (string $provider): array => [ 'provider' => $provider, 'domain' => $resolvedDomain, 'status' => 'SKIPPED_DISABLED_OR_NOT_CONFIGURED', 'reason' => 'Provider is in the domain discovery policy but is not executable in the current runtime configuration.', 'result_count' => 0, ], array_values(array_diff($plannedProviders, $preferred))); if ($preferred === []) { if ($searchType === SearchType::IDENTIFIER && ! $hasExplicitPreferredProviders) { $fallback = $this->searchIdentifierFallback( $request, $resolvedDomain, $domainResolution->getIdentifierResolution() !== null ? $domainResolution->getIdentifierResolution()->getNormalizedValue() : $request->getQuery() ); if ($fallback instanceof SearchResultCollection) { return $fallback; } } return $this->emptyCollection( $searchType, sprintf('No provider supports %s search for domain %s.', $searchType, $resolvedDomain) ); } $normalizedQuery = $domainResolution->getIdentifierResolution() !== null ? $domainResolution->getIdentifierResolution()->getNormalizedValue() : $request->getQuery(); $concreteRequest = new SearchRequest( $normalizedQuery, $request->getLimit(), $request->getOffset(), $searchType, $resolvedDomain, $preferred, $searchIntent, $searchType === SearchType::IDENTIFIER || $domainResolution->getProviderChain()->isParallel() ? 'collect' : $request->getExecutionMode(), $request->getTimeoutBudgetMs(), $request->getBrand() ); $primaryResult = $this->searchEngine->search($concreteRequest); if ( $searchType === SearchType::IDENTIFIER && ! $hasExplicitPreferredProviders && $primaryResult->getTotal() === 0 ) { $fallback = $this->searchIdentifierFallback($request, $resolvedDomain, $normalizedQuery, $primaryResult); if ($fallback instanceof SearchResultCollection) { return $fallback; } } return $this->toCollection($primaryResult, $searchType, $skippedProviderTrace); } private function searchIdentifierFallback( SearchRequest $request, string $mediaType, string $normalizedQuery, $primaryResult = null ): ?SearchResultCollection { $fallbackProviders = $this->availableIdentifierFallbackProviders($mediaType); if ($fallbackProviders === []) { return null; } $primaryTimeMs = is_object($primaryResult) && method_exists($primaryResult, 'getProviderResponseTimeMs') ? (float) $primaryResult->getProviderResponseTimeMs() : 0.0; $remainingBudgetMs = max(1000, $request->getTimeoutBudgetMs() - (int) $primaryTimeMs); $fallbackRequest = new SearchRequest( $normalizedQuery, $request->getLimit(), $request->getOffset(), SearchType::IDENTIFIER, $mediaType, $fallbackProviders, SearchIntent::LOOKUP, 'collect', $remainingBudgetMs, $request->getBrand() ); $fallbackResult = $this->searchEngine->search($fallbackRequest); if (! is_object($primaryResult)) { return $this->toCollection($fallbackResult, SearchType::IDENTIFIER); } $trace = array_merge( $primaryResult->getProviderTrace(), [[ 'provider' => 'SearchOrchestrator', 'domain' => $mediaType, 'search_type' => SearchType::IDENTIFIER, 'execution_mode' => 'fallback', 'status' => 'IDENTIFIER_WEB_FALLBACK', 'reason' => 'Regular identifier providers returned no result; running exact-code web fallback.', ]], $fallbackResult->getProviderTrace() ); return new SearchResultCollection( $fallbackResult->getItems(), $fallbackResult->getTotal(), SearchType::IDENTIFIER, $fallbackResult->getProviderUsed(), 'identifier-providers', $primaryResult->getProviderAttempts() + $fallbackResult->getProviderAttempts(), $primaryResult->getProviderResponseTimeMs() + $fallbackResult->getProviderResponseTimeMs(), $trace, $fallbackResult->getRemoteTotal(), $fallbackResult->getKnownQualifiedTotal(), $fallbackResult->hasRemoteContinuation(), $fallbackResult->getProviderContinuations() ); } private function availableIdentifierFallbackProviders(string $mediaType, bool $ignoreExecutionPlan = false): array { return $this->filterAvailableProviders( RuntimeProviderCatalog::identifierFallbackProviders($mediaType), SearchType::IDENTIFIER, $mediaType, $ignoreExecutionPlan, true ); } private function filterAvailableProviders( array $providers, string $searchType, string $mediaType, bool $ignoreExecutionPlan = false, bool $includeIdentifierFallbacks = false ): array { $compatible = $this->filterProvidersForSearchType( $providers, $searchType, $mediaType, $includeIdentifierFallbacks ); $registered = $ignoreExecutionPlan && method_exists($this->providerRegistry, 'getProvidersByPriorityIgnoringPlanForMedia') ? $this->providerRegistry->getProvidersByPriorityIgnoringPlanForMedia($mediaType) : $this->providerRegistry->getProvidersByPriorityForMedia($mediaType); $registeredKeys = []; foreach ($registered as $provider) { if (! is_object($provider) || ! method_exists($provider, 'getName')) { continue; } $key = $this->providerRegistry->providerKey((string) $provider->getName()); if ($key !== '') { $registeredKeys[$key] = true; } } return array_values(array_filter( $compatible, fn (string $provider): bool => isset($registeredKeys[$this->providerRegistry->providerKey($provider)]) )); } private function filterProvidersForSearchType( array $providers, string $searchType, string $mediaType, bool $includeIdentifierFallbacks = false ): array { return array_values(array_filter( $providers, static fn ($provider): bool => is_string($provider) && $provider !== '' && RuntimeProviderCatalog::supportsSearchType($provider, $searchType, $mediaType) && ( $includeIdentifierFallbacks || $searchType !== SearchType::IDENTIFIER || ! RuntimeProviderCatalog::isIdentifierFallbackOnly($provider) ) )); } private function detectAutomaticTitleDomain(SearchRequest $request): ?string { if (SearchType::normalize($request->getSearchType()) === SearchType::IDENTIFIER) { return null; } $query = trim($request->getQuery()); if ($query === '' || preg_match('/^[0-9X\-\s]{8,18}$/i', $query) === 1) { return null; } $normalized = strtolower(trim((string) preg_replace('/[^\p{L}\p{N}]+/u', ' ', $query))); $padded = ' ' . $normalized . ' '; foreach ([ 'orologio', 'orologi', 'gioiello', 'gioielli', 'gioielleria', 'smartwatch', 'watch', 'watches', 'bracciale', 'bracciali', 'collana', 'collane', 'orecchino', 'orecchini', 'ciondolo', 'ciondoli', 'pendente', 'pendenti', 'anello', 'anelli', 'perla', 'perle', 'targhetta', 'charm', 'charms', 'bracelet', 'necklace', 'earrings', 'pendant', 'ring', ] as $token) { if (str_contains($padded, ' ' . $token . ' ')) { return MediaType::JEWELRY; } } return null; } /** @return array{0:string,1:string,2:string} */ private function toCollection($result, string $searchType, array $additionalTrace = []): SearchResultCollection { return new SearchResultCollection( $result->getItems(), $result->getTotal(), $searchType, $result->getProviderUsed(), $result->getProviderFallbackFrom(), $result->getProviderAttempts(), $result->getProviderResponseTimeMs(), array_merge($additionalTrace, $result->getProviderTrace()), $result->getRemoteTotal(), $result->getKnownQualifiedTotal(), $result->hasRemoteContinuation(), $result->getProviderContinuations() ); } private function emptyCollection(string $searchType, string $reason = ''): SearchResultCollection { return new SearchResultCollection( [], 0, $searchType, null, null, 0, 0.0, $reason !== '' ? [['provider' => 'DomainEngine', 'status' => 'unresolved', 'reason' => $reason]] : [] ); } private function resolveSearchIntent(SearchRequest $request, $domainResolution): string { $explicit = $request->getSearchIntent(); if ($explicit !== null && $explicit !== SearchIntent::AUTO) { return $explicit; } if ($domainResolution->getIdentifierResolution() !== null) { return SearchIntent::LOOKUP; } return SearchIntent::DISCOVERY; } private function resolveSearchType(SearchRequest $request, string $intent, $domainResolution): string { $explicit = SearchType::normalize($request->getSearchType()); if ($explicit !== null) { return $explicit; } if ($intent === SearchIntent::LOOKUP) { return SearchType::IDENTIFIER; } return SearchType::TITLE; } }