registry = $registry; $this->chainStrategy = $chainStrategy; $this->logger = $logger; $this->resultQualifier = $resultQualifier ?? new SearchResultQualifier(); $this->executionPolicy = $executionPolicy ?? new ProviderExecutionPolicy(); $this->productDataMerger = $productDataMerger ?? new ProductDataMerger(); $this->identifierMatcher = new ExactIdentifierMatcher(); $this->inspectorBridge = $inspectorBridge; } public function search(SearchContext $context): array { $providers = $this->getProvidersForRequest($context); $results = []; $qualifiedItems = []; $startedAt = microtime(true); $targetUnique = max(1, $context->getRequest()->getLimit() + max(0, $context->getRequest()->getOffset())); $strongIdentifierFound = false; foreach ($providers as $providerIndex => $provider) { if (($strongIdentifierFound || ($context->getRequest()->isIdentifierSearch() && !RuntimeProviderCatalog::hasCapability($provider->getName(), 'enrichment') && count(array_filter($qualifiedItems, fn ($item): bool => $item instanceof \TFGraphics\SmartImport\Domain\DTO\ProductDTO && $this->identifierMatcher->matches($item, $context->getRequest()->getQuery()))) > 0)) && RuntimeProviderCatalog::isIdentifierFallbackOnly($provider->getName()) && (! RuntimeProviderCatalog::hasCapability($provider->getName(), 'enrichment') || ! $this->needsIdentifierCompletionForProvider($provider->getName(), $qualifiedItems, $context->getRequest()->getQuery()))) { continue; } if ($context->getRequest()->isDiscoveryMode() && count($this->productDataMerger->mergeProducts($qualifiedItems)) >= $targetUnique) { break; } if (! $this->executionPolicy->canExecute($provider->getName())) { $skipped = new ProviderResult($provider->getName(), new SearchResult([], 0), ProviderStatus::CIRCUIT_OPEN, 0.0, 'Provider circuit is open.', array_merge($this->requestDiagnostics($context), [ 'circuit_state' => $this->executionPolicy->state($provider->getName()), 'execution_policy' => $this->executionPolicy->metadata($provider->getName()), 'circuit_details' => $this->executionPolicy->details($provider->getName()), ])); $results[] = $skipped; $this->recordInspectorAttempt($skipped, $context); continue; } $elapsedMs = (microtime(true) - $startedAt) * 1000; if ($elapsedMs >= $context->getRequest()->getTimeoutBudgetMs()) { $skipped = new ProviderResult( $provider->getName(), new SearchResult([], 0), ProviderStatus::TIMEOUT, 0.0, 'Overall provider timeout budget exhausted.', $this->requestDiagnostics($context) ); $results[] = $skipped; $this->recordInspectorAttempt($skipped, $context); break; } $remainingMs = max(1, $context->getRequest()->getTimeoutBudgetMs() - (int) $elapsedMs); $effectiveTimeoutMs = min($context->getRequest()->getPerAttemptTimeoutBudgetMs(), $remainingMs); if (strcasecmp($provider->getName(), 'OpenBeautyFacts') === 0 && $context->getRequest()->isIdentifierSearch() && $context->getRequest()->getMediaType() !== \TFGraphics\SmartImport\Domain\Search\MediaType::BEAUTY) { $effectiveTimeoutMs = min($effectiveTimeoutMs, 1500); } // Leave a fair share for executable peers in the music title chain. if ($context->getRequest()->isTitleSearch() && $context->getRequest()->getMediaType() === 'music') { $peers = array_filter(array_slice($providers, $providerIndex + 1), fn ($peer): bool => $this->executionPolicy->canExecute($peer->getName())); $effectiveTimeoutMs = min($effectiveTimeoutMs, max(1, (int) floor($remainingMs / (1 + count($peers))))); } $attemptContext = $context->withRequest( $context->getRequest()->withTimeoutBudgetMs($effectiveTimeoutMs) ); $attempt = $this->attemptProviderSearch($provider, $attemptContext); $this->executionPolicy->record( $provider->getName(), $attempt['status'], $attempt['result']->getErrorMessage(), $attempt['result']->getDiagnostics() ); $results[] = $attempt['result']; $this->recordInspectorAttempt($attempt['result'], $attemptContext); if (ProviderStatus::isSuccess($attempt['status'])) { $qualifiedItems = array_merge($qualifiedItems, $attempt['result']->getResult()->getItems()); if ($context->getRequest()->isIdentifierSearch() && ! RuntimeProviderCatalog::isSpecialistIdentifierCatalog($provider->getName()) && (! RuntimeProviderCatalog::isIdentifierFallbackOnly($provider->getName()) || ! RuntimeProviderCatalog::hasCapability($provider->getName(), 'enrichment')) && count(array_filter($attempt['result']->getResult()->getItems(), fn ($item): bool => $item instanceof \TFGraphics\SmartImport\Domain\DTO\ProductDTO && $this->identifierMatcher->matches($item, $context->getRequest()->getQuery()))) > 0) { $strongIdentifierFound = true; } } $this->logAttempt($attempt['result'], count($results)); // Discovery may carry a bounded same-domain provider chain (primary + one fallback). // Once one provider yields qualified evidence there is no reason to collect peers from // the same domain; later domains are explored by the outer UAS discovery queue. if ($context->getRequest()->isDiscoveryMode() && count($context->getRequest()->getPreferredProviders()) === 1 && ProviderStatus::isSuccess($attempt['status'])) { break; } if ($context->getRequest()->getExecutionMode() === 'failover' && ProviderStatus::isSuccess($attempt['status'])) { break; } } return $results; } private function needsIdentifierCompletionForProvider(string $provider, array $items, string $query): bool { $domains = RuntimeProviderCatalog::all()[$provider]['identifier_completion_domains'] ?? []; if ($domains === []) { return $this->needsIdentifierCompletion($items, $query); } foreach ($items as $item) { if (!$item instanceof \TFGraphics\SmartImport\Domain\DTO\ProductDTO || !$this->identifierMatcher->matches($item, $query)) { continue; } $normalized = (new ProductEvidenceNormalizer())->normalize($item); $attributes = \TFGraphics\SmartImport\Search\UniversalAcquisition\MovieWorkEnrichment::attributes($normalized); if (!in_array($attributes['media_type'] ?? '', $domains, true)) { continue; } if ($this->needsIdentifierCompletion([$normalized], $query) || empty($attributes['director']) || empty($attributes['cast'])) { return true; } } return false; } private function needsIdentifierCompletion(array $items, string $query): bool { foreach ($items as $item) { if (! $this->identifierMatcher->matches($item, $query)) { continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- Standalone-test fallback is used only when WordPress is unavailable. $description = trim(function_exists('wp_strip_all_tags') ? wp_strip_all_tags((string) $item->getDescription()) : \strip_tags((string) $item->getDescription())); $attributes = []; foreach ($item->getAttributes() as $attribute) { $attributes[strtolower($attribute->getName())] = trim($attribute->getValue()); } $music = in_array('music', [$attributes['media_type'] ?? '', $attributes['observation_media_type'] ?? ''], true); if ($item->getImages() === [] || $description === '' || strcasecmp($description, trim($item->getName())) === 0 || ($music && (($attributes['artist'] ?? '') === '' || ($attributes['label'] ?? '') === ''))) { return true; } } return false; } private function attemptProviderSearch(ProviderInterface $provider, SearchContext $context, int $retryAttempt = 0): array { $startedAt = microtime(true); $diagnostics = $this->requestDiagnostics($context); $diagnostics['retry_attempt'] = max(0, $retryAttempt); try { $rawSearchResult = $provider->search($context->getRequest()); $diagnostics = $this->mergeProviderDiagnostics($provider, $diagnostics); if ($context->getRequest()->isIdentifierSearch() && RuntimeProviderCatalog::isIdentifierFallbackOnly($provider->getName())) { $exact = array_values(array_filter($rawSearchResult->getItems(), fn ($item): bool => $item instanceof \TFGraphics\SmartImport\Domain\DTO\ProductDTO && $this->identifierMatcher->matches($item, $context->getRequest()->getQuery()))); $diagnostics['identifier_mismatch_count'] = count($rawSearchResult->getItems()) - count($exact); $rawSearchResult = new SearchResult($exact, count($exact)); } $qualification = $this->resultQualifier->qualify($rawSearchResult, $context->getRequest()); $searchResult = $qualification['result']; $diagnostics['raw_result_count'] = (int) ($qualification['raw_result_count'] ?? 0); $diagnostics['qualified_result_count'] = (int) ($qualification['qualified_result_count'] ?? 0); $diagnostics['rejected_result_count'] = (int) ($qualification['rejected_result_count'] ?? 0); $diagnostics['rejection_reasons'] = is_array($qualification['rejection_reasons'] ?? null) ? $qualification['rejection_reasons'] : []; $diagnostics['requested_product_type'] = (string) ($qualification['requested_product_type'] ?? ''); $status = $searchResult->getTotal() > 0 ? ProviderStatus::SUCCESS : ProviderStatus::NOT_FOUND; // A public page failure is not a credential failure of the discovery API. $sourceStatus = (string)($diagnostics['source_acquisition_status'] ?? ''); if ($searchResult->getTotal() === 0 && in_array($sourceStatus, [ ProviderStatus::TIMEOUT, ProviderStatus::HTTP_ERROR, ProviderStatus::NETWORK_ERROR, ProviderStatus::PROVIDER_UNAVAILABLE, ProviderStatus::LOW_CONFIDENCE, ], true)) { $status = $sourceStatus; } return [ 'status' => $status, 'result' => new ProviderResult( $provider->getName(), $searchResult, $status, (microtime(true) - $startedAt) * 1000, null, $diagnostics ), ]; } catch (\Throwable $exception) { $diagnostics = $this->mergeProviderDiagnostics($provider, $diagnostics); $status = ProviderStatus::fromThrowable($exception); $diagnostics['exception_class'] = $exception::class; return [ 'status' => $status, 'result' => new ProviderResult( $provider->getName(), new SearchResult([], 0), $status, (microtime(true) - $startedAt) * 1000, $exception->getMessage(), $diagnostics ), ]; } } private function mergeProviderDiagnostics(ProviderInterface $provider, array $diagnostics): array { if (! method_exists($provider, 'getLastDiagnostics')) { return $diagnostics; } try { $providerDiagnostics = $provider->getLastDiagnostics(); } catch (\Throwable) { return $diagnostics; } if (! is_array($providerDiagnostics)) { return $diagnostics; } $allowed = [ 'backend', 'index_state', 'lookup_ms', 'index_open_ms', 'search_ms', 'primary_search_ms', 'fallback_search_ms', 'fallback_used', 'remote_took_ms', 'remote_timed_out', 'extract_ms', 'sources_examined', 'sources_received', 'extract_attempts', 'attempt_budget_ms', 'search_elapsed_ms', 'remaining_after_search_ms', 'extract_elapsed_ms', 'budget_exhausted', 'structured_sources', 'accepted_sources', 'remote_pages_fetched', 'unavailable_sources', 'source_acquisition_status', 'source_acquisition_reason', 'source_verification_complete', 'discovery_partial', 'discovery_failure_status', 'local_skipped_sources', 'structured_products', 'identifier_conflicts', 'query_variant', 'search_fallback_used', 'normalized_result_count', 'primary_error', 'http_status', 'status_code', 'failure_class', 'environment', 'configured', ]; foreach ($allowed as $key) { if (array_key_exists($key, $providerDiagnostics)) { $diagnostics[$key] = $providerDiagnostics[$key]; } } if (isset($providerDiagnostics['source_details']) && is_array($providerDiagnostics['source_details'])) { $diagnostics['source_details'] = \TFGraphics\SmartImport\Enrichment\SourceDiagnostic::rows($providerDiagnostics['source_details']); } $summary = []; if (! empty($diagnostics['backend'])) { $summary[] = 'backend=' . (string) $diagnostics['backend']; } if (isset($diagnostics['index_state'])) { $summary[] = 'index=' . (string) $diagnostics['index_state']; $summary[] = 'lookup=' . round((float) ($diagnostics['lookup_ms'] ?? 0), 2) . 'ms'; $summary[] = 'index_open=' . round((float) ($diagnostics['index_open_ms'] ?? 0), 2) . 'ms'; } if (isset($diagnostics['search_ms'])) { $summary[] = 'search=' . round((float) $diagnostics['search_ms'], 2) . 'ms'; } if (isset($diagnostics['extract_ms']) && (float) $diagnostics['extract_ms'] > 0) { $summary[] = 'extract=' . round((float) $diagnostics['extract_ms'], 2) . 'ms'; } if (isset($diagnostics['sources_examined'])) { $summary[] = 'sources=' . max(0, (int) $diagnostics['sources_examined']); } if (isset($diagnostics['extract_attempts'])) { $summary[] = 'fetches=' . max(0, (int) $diagnostics['extract_attempts']); } if (isset($diagnostics['remote_pages_fetched'])) { $summary[] = 'http_fetches=' . (int)$diagnostics['remote_pages_fetched']; } if (!empty($diagnostics['source_acquisition_status'])) { $summary[] = 'source_status=' . $diagnostics['source_acquisition_status']; } if (!empty($diagnostics['source_acquisition_reason'])) { $summary[] = 'source_reason=' . $diagnostics['source_acquisition_reason']; } foreach (['local_skipped_sources','structured_products','identifier_conflicts'] as $counter) { if (isset($diagnostics[$counter])) { $summary[] = $counter . '=' . (int)$diagnostics[$counter]; } } if (isset($diagnostics['structured_sources'])) { $summary[] = 'structured=' . max(0, (int) $diagnostics['structured_sources']); } if (! empty($diagnostics['fallback_used'])) { $summary[] = 'provider-fallback=legacy'; } if (isset($diagnostics['remote_took_ms']) && (int) $diagnostics['remote_took_ms'] > 0) { $summary[] = 'remote=' . (int) $diagnostics['remote_took_ms'] . 'ms'; } if ($summary !== []) { $diagnostics['reason'] = implode(', ', $summary); } return $diagnostics; } private function getProvidersForRequest(SearchContext $context): array { $request = $context->getRequest(); $preferredProviders = $this->normalizePreferredProviders($request->getPreferredProviders()); $providers = $preferredProviders !== [] && method_exists($this->registry, 'getProvidersByPriorityIgnoringPlanForMedia') ? $this->registry->getProvidersByPriorityIgnoringPlanForMedia($request->getMediaType()) : $this->registry->getProvidersByPriorityForMedia($request->getMediaType()); if ($request->isIdentifierSearch()) { // Identity fallbacks stay behind specialist catalogs even after a settings reorder. usort($providers, static fn (ProviderInterface $left, ProviderInterface $right): int => (int) RuntimeProviderCatalog::isIdentifierFallbackOnly($left->getName()) <=> (int) RuntimeProviderCatalog::isIdentifierFallbackOnly($right->getName())); } if ($preferredProviders === []) { return $providers; } $registry = $this->registry; $selected = array_values(array_filter( $providers, static fn (ProviderInterface $provider): bool => in_array($registry->providerKey($provider->getName()), $preferredProviders, true) )); return $this->boundedDiscoveryProviders($context, $providers, $selected); } /** * UAS discovery targets one media domain at a time. When that target pins a * single preferred provider, keep one same-domain fallback available so an * auth/rate-limit/timeout/not-found from the primary does not make the whole * domain unreachable. This never expands Generic commerce and never crosses * domain boundaries; the outer UAS queue still owns cross-domain fairness. * * @param ProviderInterface[] $domainProviders * @param ProviderInterface[] $selected * @return ProviderInterface[] */ private function boundedDiscoveryProviders(SearchContext $context, array $domainProviders, array $selected): array { $request = $context->getRequest(); if (! $request->isDiscoveryMode() || count($request->getPreferredProviders()) !== 1) { return $selected; } $mediaType = \TFGraphics\SmartImport\Domain\Search\MediaType::normalize($request->getMediaType()); if (! in_array($mediaType, [ \TFGraphics\SmartImport\Domain\Search\MediaType::BOOK, \TFGraphics\SmartImport\Domain\Search\MediaType::MOVIE, \TFGraphics\SmartImport\Domain\Search\MediaType::MUSIC, \TFGraphics\SmartImport\Domain\Search\MediaType::GAME, ], true)) { return $selected; } $selectedKeys = []; foreach ($selected as $provider) { $selectedKeys[$this->registry->providerKey($provider->getName())] = true; } foreach ($domainProviders as $provider) { $key = $this->registry->providerKey($provider->getName()); if (isset($selectedKeys[$key])) { continue; } if ($request->getSearchType() !== null && RuntimeProviderCatalog::descriptor($provider->getName()) !== [] && !RuntimeProviderCatalog::supportsSearchType($provider->getName(), $request->getSearchType(), $mediaType)) { continue; } $selected[] = $provider; break; } return array_slice($selected, 0, 2); } private function normalizePreferredProviders(array $preferredProviders): array { return array_values(array_filter(array_map( fn ($provider): string => is_string($provider) ? $this->registry->providerKey($provider) : '', $preferredProviders ), static fn (string $provider): bool => $provider !== '')); } private function requestDiagnostics(SearchContext $context): array { $request = $context->getRequest(); return [ 'search_type' => $request->getSearchType(), 'media_type' => $request->getMediaType(), 'brand' => $request->getBrand(), 'execution_mode' => $request->getExecutionMode(), 'limit' => $request->getLimit(), 'offset' => $request->getOffset(), 'timeout_budget_ms' => $request->getTimeoutBudgetMs(), 'per_attempt_timeout_budget_ms' => $request->getPerAttemptTimeoutBudgetMs(), 'effective_timeout_ms' => $request->getTimeoutBudgetMs(), ]; } private function logAttempt(ProviderResult $providerResult, int $attempt): void { if (! $this->logger instanceof Logger) { return; } $context = array_merge($providerResult->getDiagnostics(), [ 'provider' => $providerResult->getProviderName(), 'status' => $providerResult->getStatus(), 'response_time_ms' => round($providerResult->getResponseTimeMs(), 2), 'provider_attempt' => $attempt, 'fallback_triggered' => $this->chainStrategy->shouldFallback($providerResult->getStatus()), ]); if ($providerResult->getErrorMessage() !== null) { $context['error'] = $providerResult->getErrorMessage(); } if (! ProviderStatus::isSuccess($providerResult->getStatus()) && $providerResult->getStatus() !== ProviderStatus::NOT_FOUND) { $this->logger->debug('Provider chain failure', $context); return; } $this->logger->info('Provider chain attempt', $context); } private function recordInspectorAttempt(ProviderResult $result, SearchContext $context): void { if (! $this->inspectorBridge instanceof ProviderAttemptInspectorBridge) { return; } try { $this->inspectorBridge->record($result, $context->getRequest()); } catch (\Throwable) { // Inspector must never affect provider execution or fallback. } } }