routingTable ??= new ProviderRoutingTable(); $this->vocabulary ??= new ProductTaxonomyVocabulary(); } /** @return DomainCandidatePlan[] */ public function plan(SearchRequest $request): array { $query = $this->normalize($request->getQuery()); $normalizedBrand = $this->normalize($request->getBrand()); $hasStructuredBrand = $normalizedBrand !== ''; $plans = []; $taxonomyMatches = $hasStructuredBrand && $query === $normalizedBrand ? [] : $this->vocabulary->matches($query); foreach ($taxonomyMatches as $domain => $matched) { if ($matched === []) { continue; } $confidence = min(0.98, 0.72 + count($matched) * 0.08 + ($hasStructuredBrand ? 0.04 : 0.0)); $plans[] = $this->createPlan($request, $domain, $confidence, 'taxonomy: ' . implode(', ', $matched), $hasStructuredBrand); } if ($plans === [] && $hasStructuredBrand) { // A company name without product taxonomy is domain-neutral. Keep // every commerce candidate equally ambiguous and let provider // capability/cost order drive progressive execution. foreach ([MediaType::GENERIC, MediaType::FOOD, MediaType::BEAUTY, MediaType::JEWELRY] as $domain) { $plans[] = $this->createPlan($request, $domain, 0.50, 'structured-brand domain-neutral candidate', true); } } elseif ($hasStructuredBrand && ! $this->hasDomain($plans, MediaType::GENERIC)) { $plans[] = $this->createPlan($request, MediaType::GENERIC, 0.35, 'structured-brand generic fallback', true); } usort($plans, static fn (DomainCandidatePlan $a, DomainCandidatePlan $b): int => ($b->getConfidence() <=> $a->getConfidence()) ?: ($a->getEstimatedCost() <=> $b->getEstimatedCost()) ?: strcmp($a->getDomain(), $b->getDomain())); return $plans; } private function createPlan(SearchRequest $request, string $domain, float $confidence, string $reason, bool $requiresBrand = false): DomainCandidatePlan { $providers = $domain === MediaType::JEWELRY ? RuntimeProviderCatalog::titleDiscoveryProviders($domain) : $this->routingTable->getChain($domain)->getProviders(); return new DomainCandidatePlan( $domain, $confidence, $reason, $providers, $this->executionModeFor($request, $domain), $this->estimatedCost($providers, $domain, $requiresBrand), $domain === MediaType::JEWELRY ); } public function executionModeFor(SearchRequest $request, string $domain): string { if (MediaType::normalize($domain) !== MediaType::JEWELRY || $request->isIdentifierSearch() || $request->isImageSearch()) { return $request->getExecutionMode(); } if ($request->getSearchIntent() === SearchIntent::LOOKUP) { return 'failover'; } if ($request->getSearchIntent() === SearchIntent::DISCOVERY) { return 'discovery'; } $queryTokens = preg_split('/\s+/u', $this->normalize($request->getQuery())) ?: []; $generic = array_fill_keys(array_merge($this->vocabulary->termsForDomain(MediaType::JEWELRY), [ 'donna', 'donne', 'uomo', 'uomini', 'bambino', 'bambina', 'unisex', ]), true); $distinctive = array_filter($queryTokens, static fn (string $token): bool => strlen($token) >= 2 && ! isset($generic[$token]) ); return count(array_unique($distinctive)) >= 2 ? 'failover' : 'discovery'; } /** @param string[] $providers */ private function estimatedCost(array $providers, string $domain, bool $requiresBrand): int { $total = 0.0; $weight = 1.0; $count = 0; foreach ($providers as $provider) { if (! RuntimeProviderCatalog::supportsSearchType($provider, SearchType::TITLE, $domain)) { continue; } $metadata = RuntimeProviderCatalog::executionMetadata($provider); if ($metadata === []) { continue; } $costWeight = match ((string) ($metadata['cost_class'] ?? 'unknown')) { 'free' => 0, 'freemium' => 500000, 'paid' => 1000000, default => 750000, }; $providerCost = $costWeight + (! empty($metadata['fallback_only']) ? 500000 : 0) + ($requiresBrand && empty($metadata['supports_brand']) ? 250000 : 0) + max(0, (int) ($metadata['estimated_latency_ms'] ?? 30000)) * 10 + max(0, (int) ($metadata['priority'] ?? 999)); $total += $providerCost * $weight; $weight = max(0.25, $weight * 0.60); $count++; } return $count === 0 ? intdiv(PHP_INT_MAX, 4) : (int) round($total); } /** @param DomainCandidatePlan[] $plans */ private function hasDomain(array $plans, string $domain): bool { return count(array_filter($plans, static fn (DomainCandidatePlan $plan): bool => $plan->getDomain() === $domain)) > 0; } private function containsPhrase(string $text, string $phrase): bool { return str_contains(' ' . $text . ' ', ' ' . $this->normalize($phrase) . ' '); } private function normalize(string $value): string { $value = function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value); return trim((string) preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value)); } }