productDataMerger = $productDataMerger ?? new ProductDataMerger(); $this->resultRanker = $resultRanker ?? new SearchResultRanker(); $this->inspector = $inspector; } public function merge( array $providerResults, ?int $limit = null, int $offset = 0, bool $mergeProducts = true, string $query = '', bool $identifierSearch = false, bool $qualifyTitleResults = false, ?string $mediaType = null ): SearchResult { $mergeStarted = microtime(true); $inspectionStarted = microtime(true); try { $this->inspector?->observeProviderResults($providerResults); } catch (\Throwable) {} $providerInspectionMs = (microtime(true) - $inspectionStarted) * 1000; $items = []; $providerUsed = null; $providerFallbackFrom = null; $providerAttempts = count($providerResults); $providerResponseTimeMs = 0.0; $providerTrace = []; $previousProvider = null; $successfulTotals = []; $successfulReturnedCounts = []; $successfulProviders = []; $successfulAppliedOffsets = []; $successfulPages = []; $rawResultCount = 0; $qualifiedResultCount = 0; $remoteTotalCount = 0; foreach ($providerResults as $providerResult) { if (! $providerResult instanceof ProviderResult) { continue; } $result = $providerResult->getResult(); $returnedCount = count($result->getItems()); $remoteTotal = max(0, $result->getTotal()); $diagnostics = $providerResult->getDiagnostics(); $rawResultCount += max(0, (int) ($diagnostics['raw_result_count'] ?? $returnedCount)); $qualifiedResultCount += max(0, (int) ($diagnostics['qualified_result_count'] ?? $returnedCount)); $trace = array_merge($diagnostics, [ 'provider' => $providerResult->getProviderName(), 'status' => $providerResult->getStatus(), 'response_time_ms' => round($providerResult->getResponseTimeMs(), 2), // result_count means the records actually returned to the suite. // Some APIs (notably RAWG) expose a very broad remote index total // which is useful diagnostically but misleading as a local result count. 'result_count' => $returnedCount, ]); if ($remoteTotal !== $returnedCount) { $trace['remote_total'] = $remoteTotal; } if ($providerResult->getErrorMessage() !== null) { $trace['error'] = $providerResult->getErrorMessage(); } $providerTrace[] = $trace; if ($providerResult->getStatus() !== ProviderStatus::SUCCESS) { $previousProvider = $providerResult->getProviderName(); continue; } $items = array_merge($items, $result->getItems()); $successfulTotals[] = $remoteTotal; $successfulReturnedCounts[] = $returnedCount; $remoteTotalCount += $remoteTotal; $successfulProviders[] = $providerResult->getProviderName(); $successfulAppliedOffsets[] = $result->getAppliedOffset(); $successfulPages[] = [ 'provider' => $providerResult->getProviderName(), 'returned' => $returnedCount, 'remote_total' => $remoteTotal, 'offset' => $result->getAppliedOffset(), ]; if ($providerUsed === null) { $providerUsed = $providerResult->getProviderName(); $providerResponseTimeMs = $providerResult->getResponseTimeMs(); if ($previousProvider !== null && $previousProvider !== $providerUsed) { $providerFallbackFrom = $previousProvider; } } $previousProvider = $providerResult->getProviderName(); } // Items arriving here have already passed ProviderManager qualification. // Raw and qualified counts are therefore sourced from its diagnostics, // never inferred from post-deduplication cardinality. $qualifiedItemCount = count($items); if ($qualifiedResultCount === 0 && $qualifiedItemCount > 0) { $qualifiedResultCount = $qualifiedItemCount; } if ($rawResultCount === 0 && $qualifiedItemCount > 0) { $rawResultCount = $qualifiedItemCount; } $qualifiedItemsForInspector = $items; $identityConflicts = []; if ($mergeProducts) { $items = $this->productDataMerger->mergeProducts($items); $identityConflicts = $this->productDataMerger->getIdentityConflicts(); } if ($identityConflicts !== []) { $providerTrace[] = [ 'provider' => 'SearchMerger', 'status' => 'REJECTED_IDENTITY_CONFLICT', 'reason' => 'Fonti incompatibili per lo stesso codice: integratore e bevanda alcolica. Scheda esclusa dalla fusione automatica.', 'conflicts' => $identityConflicts, 'result_count' => 0, 'response_time_ms' => 0.0, ]; } $normalizer = new ProductEvidenceNormalizer(); $items = array_map(static fn ($item) => $item instanceof \TFGraphics\SmartImport\Domain\DTO\ProductDTO ? $normalizer->normalize($item) : $item, $items); $titleItemsFiltered = false; $titleRejectedCount = 0; $skipBroadTitleQualification = MediaType::normalize($mediaType) === MediaType::MEDICINE; if ($qualifyTitleResults && ! $identifierSearch && $query !== '' && ! $skipBroadTitleQualification) { $beforeTitleQualification = count($items); $items = $this->resultRanker->qualifyBroadTitle($items, $query, $mediaType); $titleRejectedCount = $beforeTitleQualification - count($items); $titleItemsFiltered = $titleRejectedCount > 0; } if ($query !== '') { $items = $this->resultRanker->rank($items, $query, $identifierSearch, $mediaType); } $inspectionStarted = microtime(true); try { $this->inspector?->observeMerge($qualifiedItemsForInspector, $items); } catch (\Throwable) {} $mergeInspectionMs = (microtime(true) - $inspectionStarted) * 1000; $mergedItemCount = count($items); $sliceOffset = max(0, $offset); if (count($successfulAppliedOffsets) === 1 && $successfulAppliedOffsets[0] === $sliceOffset) { $sliceOffset = 0; } $providerTrace[] = [ 'provider' => 'SearchMerger', 'reason' => 'inspector_provider_ms=' . round($providerInspectionMs, 2) . ', inspector_merge_ms=' . round($mergeInspectionMs, 2) . ', merge_core_ms=' . round(max(0.0, (microtime(true) - $mergeStarted) * 1000 - $providerInspectionMs - $mergeInspectionMs), 2), 'status' => $mergedItemCount > 0 ? ProviderStatus::FOUND : ProviderStatus::NOT_FOUND, 'raw_result_count' => $rawResultCount, 'merged_result_count' => $mergedItemCount, 'raw_results' => $rawResultCount, 'unique_results' => $mergedItemCount, 'qualified_results' => $qualifiedResultCount, 'identity_conflict_records' => array_sum(array_column($identityConflicts, 'record_count')), 'title_rejected_count' => $titleRejectedCount, 'duplicates_removed' => max(0, $qualifiedResultCount - $mergedItemCount - $titleRejectedCount - array_sum(array_column($identityConflicts, 'record_count'))), 'remote_total' => $remoteTotalCount, 'limit' => $limit, 'offset' => max(0, $offset), 'provider_offset_applied' => $sliceOffset === 0 && $offset > 0, 'result_count' => $limit === null ? $mergedItemCount : count(array_slice($items, $sliceOffset, max(1, $limit))), ]; $providerTrace[array_key_last($providerTrace)]['page_results'] = $providerTrace[array_key_last($providerTrace)]['result_count']; // With exactly one successful provider its reported total normally drives // pagination. RAWG is the exception: its search count is the size of a // very broad remote index and can be thousands even when the API returns // only a small relevant page. Expose the actual accepted page count in // the suite and keep the remote count only in provider diagnostics. $providerContinuations = []; $allRemoteCollectionsComplete = $successfulPages !== []; foreach ($successfulPages as $page) { $providerName = (string) $page['provider']; $providerKey = strtolower($providerName); $returned = (int) $page['returned']; $pageOffset = (int) $page['offset']; $remoteTotal = (int) $page['remote_total']; // A remote total is not proof that a provider can advance. Only // providers with an audited exact-offset contract may publish a // numeric continuation, and a continuation response must confirm // that it applied the requested offset rather than repeating page 1. $pageable = RuntimeProviderCatalog::supportsVerifiedOffsetPagination($providerName) && $pageOffset === max(0, $offset); $nextOffset = $pageOffset + $returned; if ($pageable && $returned > 0 && $nextOffset < $remoteTotal) { $providerContinuations[] = [ 'provider' => $providerName, 'next_offset' => $nextOffset, 'remote_total' => $remoteTotal, 'page_size' => $returned, ]; } $allRemoteCollectionsComplete = $allRemoteCollectionsComplete && $pageOffset === 0 && ($providerKey === 'rawg' || $returned >= $remoteTotal); } $singleRemoteTotal = count($successfulTotals) === 1 ? $successfulTotals[0] : null; $singleProvider = strtolower((string) ($successfulProviders[0] ?? '')); $singleReturnedCount = count($successfulTotals) === 1 ? $successfulReturnedCounts[0] : 0; $singleAppliedOffset = count($successfulAppliedOffsets) === 1 ? $successfulAppliedOffsets[0] : 0; $remoteTotalIsPageable = $singleRemoteTotal !== null && $singleProvider !== 'rawg'; $remoteContinuation = $providerContinuations !== []; $completeRemoteCollection = $allRemoteCollectionsComplete; $total = count($items); if ($remoteContinuation || $singleAppliedOffset > 0) { // SearchResult::total remains the compatibility pagination bound; // dedicated metadata below keeps it distinct from qualified count. $paginationBounds = array_column($providerContinuations, 'remote_total'); $total = max($total, $singleRemoteTotal ?? 0, ...$paginationBounds); } elseif (! $titleItemsFiltered && $identityConflicts === [] && count($successfulTotals) === 1) { if ($singleProvider !== 'rawg') { $total = max($total, $successfulTotals[0]); } } $pageItems = $limit === null ? $items : array_slice($items, $sliceOffset, max(1, $limit)); return new SearchResult( $pageItems, $total, $providerUsed, $providerFallbackFrom, $providerAttempts, $providerResponseTimeMs, $providerTrace, max(0, $offset), $remoteTotalIsPageable ? $singleRemoteTotal : null, $completeRemoteCollection ? count($items) : null, $remoteContinuation, $providerContinuations ); } }