systemMsgOn = true; } public function run(string $prompt, string $userId, bool $forceFullContext = false, string $requestContextHint = ''): Generator { $originalPrompt = trim($prompt); $prompt = $originalPrompt; $routingPrompt = $prompt; if ($prompt === '') { yield $this->systemMsg($this->agentRunnerConfig->getEmptyPromptMessage(), 'err'); return; } $shopResults = []; $primaryShopResults = []; $knowledgeChunks = []; $knowledgeEvidenceState = 'none'; $sources = []; $optimizedShopQuery = ''; $shopSearchQuery = ''; $commerceHistoryContext = ''; $shopQueryHistoryContext = ''; $attemptedShopRepair = false; $usedShopRepair = false; $shopRepairQueries = []; $shopSearchAttempted = false; $primaryShopSearchHadSystemFailure = false; $historyNotices = []; $commerceIntent = CommerceIntentLite::NONE; $shopSearchSkippedBecauseNoQuery = false; $this->agentLogger->info('Agent run started', [ 'userId' => $userId, ]); try { if ($forceFullContext) { // Full context mode is already passed to PromptBuilder. // Additional context strategies can be added here later. } yield $this->systemMsg( $this->buildProductionUiMetaMessage( stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('preparing_answer'), ragCount: null, shopCount: null, shopCountMode: $this->resolveShopCountModeForMeta( commerceIntent: $commerceIntent, shopSearchAttempted: $shopSearchAttempted, shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery ), sourceLabels: $sources, confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('checking_evidence') ), 'meta' ); yield $this->systemMsg($this->agentRunnerConfig->getAnalyzeRequestMessage(), 'think'); $normalizedPrompt = yield from $this->normalizePromptForRouting($prompt, $userId); if ($normalizedPrompt !== $prompt) { $this->agentLogger->info('Prompt normalized before routing', [ 'userId' => $userId, 'originalPrompt' => $prompt, 'normalizedPrompt' => $normalizedPrompt, ]); $routingPrompt = $normalizedPrompt; } yield $this->systemMsg($this->agentRunnerConfig->getCheckInternetSourcesMessage(), 'think'); $urlContent = $this->urlAnalyzer->extractContentFromPrompt($originalPrompt); if ($urlContent !== '') { $this->addSource($sources, $this->agentRunnerConfig->getExternalUrlSourceLabel()); } $commerceIntent = $this->detectCommerceIntentForRouting( $routingPrompt, $userId, $requestContextHint ); $originalCommerceIntent = $this->detectCommerceIntentForRouting( $originalPrompt, $userId, $requestContextHint ); if (!$this->isCommerceIntent($commerceIntent) && $this->isCommerceIntent($originalCommerceIntent)) { $this->agentLogger->info('Promoted normalized routing back to explicit original commerce intent', [ 'userId' => $userId, 'originalPrompt' => $originalPrompt, 'routingPrompt' => $routingPrompt, 'originalCommerceIntent' => $originalCommerceIntent, ]); $commerceIntent = $originalCommerceIntent; } if ($this->isCommerceIntent($commerceIntent)) { yield $this->systemMsg( $this->buildProductionUiMetaMessage( stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_routing_detected'), ragCount: null, shopCount: null, shopCountMode: $this->resolveShopCountModeForMeta( commerceIntent: $commerceIntent, shopSearchAttempted: $shopSearchAttempted, shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery ), sourceLabels: $sources, confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('checking_shop_data') ), 'meta' ); } yield $this->systemMsg($this->agentRunnerConfig->getRetrieveKnowledgeMessage(), 'think'); $knowledgeRetrievalPrompt = $this->buildKnowledgeRetrievalPrompt( prompt: $routingPrompt, userId: $userId, commerceIntent: $commerceIntent ); $usedFollowUpRetrievalContext = $knowledgeRetrievalPrompt !== $routingPrompt; $knowledgeChunks = $this->retriever->retrieve($knowledgeRetrievalPrompt); $knowledgeEvidenceState = $this->resolveKnowledgeEvidenceState($routingPrompt, $knowledgeChunks, $urlContent); if ($knowledgeChunks !== []) { $this->addSource($sources, $this->agentRunnerConfig->getRagKnowledgeSourceLabel()); } yield $this->systemMsg( $this->buildProductionUiMetaMessage( stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('rag_searched'), ragCount: count($knowledgeChunks), shopCount: null, shopCountMode: $this->resolveShopCountModeForMeta( commerceIntent: $commerceIntent, shopSearchAttempted: $shopSearchAttempted, shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery ), sourceLabels: $sources, confidenceLabel: $this->resolveRagEvidenceConfidenceLabel($knowledgeEvidenceState) ), 'meta' ); if ($usedFollowUpRetrievalContext) { $this->agentLogger->info('Knowledge retrieval used follow-up context', [ 'userId' => $userId, 'prompt' => $prompt, 'routingPrompt' => $routingPrompt, 'knowledgeRetrievalPrompt' => $knowledgeRetrievalPrompt, 'commerceIntent' => $commerceIntent, ]); } if ($this->isCommerceIntent($commerceIntent)) { yield $this->systemMsg( $this->buildProductionUiMetaMessage( stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_search_preparing'), ragCount: count($knowledgeChunks), shopCount: null, shopCountMode: 'loading', sourceLabels: $sources, confidenceLabel: $this->resolveRagEvidenceShopCheckConfidenceLabel($knowledgeEvidenceState) ), 'meta' ); yield $this->systemMsg($this->agentRunnerConfig->getOptimizeSearchMessage(), 'think'); $commerceHistoryContext = $this->buildCommerceHistoryContext($userId, $requestContextHint); $isStandaloneShopQuery = $this->shouldIsolateStandaloneShopQueryFromHistory($originalPrompt); $shopQueryHistoryContext = $isStandaloneShopQuery ? '' : $this->resolveShopQueryHistoryContext( prompt: $originalPrompt, commerceHistoryContext: $commerceHistoryContext ); if ($shopQueryHistoryContext !== '') { $this->addSource($sources, $this->agentRunnerConfig->getConversationHistorySourceLabel()); } if ($commerceHistoryContext !== '' && $shopQueryHistoryContext === '') { $this->agentLogger->info('Ignored commerce history for standalone shop query', [ 'userId' => $userId, 'prompt' => $prompt, 'routingPrompt' => $routingPrompt, 'originalPrompt' => $originalPrompt, 'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext), 'standaloneShopQueryIsolated' => $isStandaloneShopQuery, ]); } if ($isStandaloneShopQuery) { $optimizedShopQuery = ''; $shopSearchQuery = $this->guardFinalStandaloneShopSearchQuery( prompt: $originalPrompt, shopSearchQuery: $routingPrompt ); if ($shopSearchQuery === '') { $shopSearchQuery = $originalPrompt; } $this->agentLogger->info('Using deterministic standalone shop query without LLM optimizer history', [ 'userId' => $userId, 'prompt' => $prompt, 'routingPrompt' => $routingPrompt, 'originalPrompt' => $originalPrompt, 'shopSearchQuery' => $shopSearchQuery, ]); } else { $optimizedShopQuery = yield from $this->buildOptimizedShopQuery( $routingPrompt, $userId, $shopQueryHistoryContext ); $shopSearchQuery = $this->resolveShopSearchQuery( prompt: $originalPrompt, optimizedShopQuery: $optimizedShopQuery, commerceHistoryContext: $shopQueryHistoryContext, userId: $userId, currentPromptFallback: $routingPrompt ); } $guardedShopSearchQuery = $this->guardFinalStandaloneShopSearchQuery( prompt: $originalPrompt, shopSearchQuery: $shopSearchQuery ); if ($guardedShopSearchQuery !== $shopSearchQuery) { $this->agentLogger->info('Replaced standalone shop search query after final guard', [ 'userId' => $userId, 'prompt' => $prompt, 'routingPrompt' => $routingPrompt, 'optimizedShopQuery' => $optimizedShopQuery, 'unsafeShopSearchQuery' => $shopSearchQuery, 'guardedShopSearchQuery' => $guardedShopSearchQuery, ]); $shopSearchQuery = $guardedShopSearchQuery; $optimizedShopQuery = ''; } $referentialAnchoredShopSearchQuery = $this->guardReferentialShopQueryFallbackWithHistoryAnchor( prompt: $originalPrompt, shopSearchQuery: $shopSearchQuery, commerceHistoryContext: $shopQueryHistoryContext ); if ($referentialAnchoredShopSearchQuery !== $shopSearchQuery) { $this->agentLogger->info('Enriched referential shop fallback query with history anchor', [ 'userId' => $userId, 'prompt' => $prompt, 'routingPrompt' => $routingPrompt, 'optimizedShopQuery' => $optimizedShopQuery, 'shopSearchQuery' => $shopSearchQuery, 'referentialAnchoredShopSearchQuery' => $referentialAnchoredShopSearchQuery, ]); $shopSearchQuery = $referentialAnchoredShopSearchQuery; $optimizedShopQuery = ''; } $mainDeviceAnchoredShopSearchQuery = $this->guardMainDeviceReferentialShopQueryWithHistoryModelAnchor( prompt: $originalPrompt, shopSearchQuery: $shopSearchQuery, commerceHistoryContext: $commerceHistoryContext ); if ($mainDeviceAnchoredShopSearchQuery !== $shopSearchQuery) { $this->agentLogger->info('Enriched referential main-device shop query with history model anchor', [ 'userId' => $userId, 'prompt' => $prompt, 'routingPrompt' => $routingPrompt, 'optimizedShopQuery' => $optimizedShopQuery, 'shopSearchQuery' => $shopSearchQuery, 'mainDeviceAnchoredShopSearchQuery' => $mainDeviceAnchoredShopSearchQuery, ]); $shopSearchQuery = $mainDeviceAnchoredShopSearchQuery; $optimizedShopQuery = ''; } $ragAnchoredShopSearchQuery = $this->enrichShopSearchQueryWithRagAnchor( prompt: $originalPrompt, shopSearchQuery: $shopSearchQuery, knowledgeChunks: $knowledgeChunks ); if ($ragAnchoredShopSearchQuery !== $shopSearchQuery) { $this->agentLogger->info('Enriched shop search query with RAG product anchor', [ 'userId' => $userId, 'prompt' => $prompt, 'routingPrompt' => $routingPrompt, 'optimizedShopQuery' => $optimizedShopQuery, 'shopSearchQuery' => $shopSearchQuery, 'ragAnchoredShopSearchQuery' => $ragAnchoredShopSearchQuery, ]); $shopSearchQuery = $ragAnchoredShopSearchQuery; $optimizedShopQuery = ''; } $deviceAnchoredShopSearchQuery = $this->enrichGenericDeviceShopQueryWithConfiguredAnchor($shopSearchQuery); if ($deviceAnchoredShopSearchQuery !== $shopSearchQuery) { $this->agentLogger->info('Enriched generic device shop query with configured product-family anchor', [ 'userId' => $userId, 'prompt' => $prompt, 'routingPrompt' => $routingPrompt, 'optimizedShopQuery' => $optimizedShopQuery, 'shopSearchQuery' => $shopSearchQuery, 'deviceAnchoredShopSearchQuery' => $deviceAnchoredShopSearchQuery, ]); $shopSearchQuery = $deviceAnchoredShopSearchQuery; $optimizedShopQuery = ''; } $positiveFilteredShopSearchQuery = $this->filterShopQueryToPositiveTokens($shopSearchQuery); if ($positiveFilteredShopSearchQuery !== $shopSearchQuery) { $this->agentLogger->info('Filtered final shop search query to positive product tokens', [ 'userId' => $userId, 'prompt' => $prompt, 'routingPrompt' => $routingPrompt, 'optimizedShopQuery' => $optimizedShopQuery, 'shopSearchQuery' => $shopSearchQuery, 'positiveFilteredShopSearchQuery' => $positiveFilteredShopSearchQuery, ]); $shopSearchQuery = $positiveFilteredShopSearchQuery; $optimizedShopQuery = ''; } if ($shopSearchQuery === '') { $this->agentLogger->info('Commerce search skipped because no concrete shop query could be resolved', [ 'userId' => $userId, 'commerceIntent' => $commerceIntent, 'prompt' => $prompt, 'routingPrompt' => $routingPrompt, 'optimizedShopQuery' => $optimizedShopQuery, 'hasCommerceHistoryContext' => $commerceHistoryContext !== '', 'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext), 'hasRequestContextHint' => trim($requestContextHint) !== '', ]); $shopSearchSkippedBecauseNoQuery = true; $noConcreteShopQueryMessage = $this->agentRunnerConfig->getNoConcreteShopQueryMessage(); yield $this->systemMsg( $this->buildProductionUiMetaMessage( stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('more_context_needed'), ragCount: count($knowledgeChunks), shopCount: null, shopCountMode: $this->resolveShopCountModeForMeta( commerceIntent: $commerceIntent, shopSearchAttempted: $shopSearchAttempted, shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery ), sourceLabels: $sources, confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('more_context_needed'), completed: true ), 'meta' ); yield $this->systemMsg( $noConcreteShopQueryMessage, 'info' ); $this->contextService->appendHistory( $userId, $originalPrompt, $this->plainTextFromHtml($noConcreteShopQueryMessage) ); return; } else { $shopQueryPreview = $this->shopSearchService->buildSearchQueryPreview( $shopSearchQuery, $commerceIntent, $shopQueryHistoryContext ); $shopSearchDisplayQuery = $shopQueryPreview->searchText !== '' ? $shopQueryPreview->searchText : $shopSearchQuery; $shopSearchUsedOptimizedQuery = $optimizedShopQuery !== ''; yield $this->systemMsg( $this->buildShopSearchMetaMessage( query: $shopSearchDisplayQuery, commerceIntent: $commerceIntent, usedOptimizedQuery: $shopSearchUsedOptimizedQuery, originalQuery: $shopSearchQuery ), 'meta' ); $this->agentLogger->info('Commerce search prepared', [ 'userId' => $userId, 'commerceIntent' => $commerceIntent, 'usedOptimizedShopQuery' => $optimizedShopQuery !== '', 'optimizedShopQuery' => $optimizedShopQuery, 'shopSearchQuery' => $shopSearchQuery, 'hasCommerceHistoryContext' => $commerceHistoryContext !== '', 'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext), ]); yield $this->systemMsg( $this->buildProductionUiMetaMessage( stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_search_running'), ragCount: count($knowledgeChunks), shopCount: null, shopCountMode: 'loading', sourceLabels: $sources, confidenceLabel: $this->resolveRagEvidenceShopCheckConfidenceLabel($knowledgeEvidenceState) ), 'meta' ); yield $this->systemMsg( sprintf($this->agentRunnerConfig->getFetchSearchDataMessageTemplate(), $commerceIntent), 'think' ); $shopSearchAttempted = true; $primaryShopResults = $this->searchShop( $shopSearchQuery, $commerceIntent, $userId, $shopQueryHistoryContext ); $primaryShopSearchHadSystemFailure = $this->shopSearchService->hadLastSearchSystemFailure(); $primaryShopSearchFailureReason = $this->shopSearchService->getLastSearchFailureReason(); if ($primaryShopSearchHadSystemFailure) { $this->agentLogger->warning('Shop repair skipped after Store API system failure', [ 'userId' => $userId, 'commerceIntent' => $commerceIntent, 'shopSearchQuery' => $shopSearchQuery, 'failureReason' => $primaryShopSearchFailureReason, ]); $shopUnavailableMessage = $this->buildShopUnavailableMessage($primaryShopSearchFailureReason); yield $this->systemMsg( $shopUnavailableMessage, 'err' ); yield $this->systemMsg( $this->buildShopSearchMetaMessage( query: $shopSearchDisplayQuery !== '' ? $shopSearchDisplayQuery : $shopSearchQuery, commerceIntent: $commerceIntent, usedOptimizedQuery: $shopSearchUsedOptimizedQuery, originalQuery: $shopSearchQuery, completed: true, unavailable: true ), 'meta' ); $historyNotices[] = $this->buildHistoryNotice( $this->agentRunnerConfig->getProductionUiText('history_notice_shop_unavailable_title'), $primaryShopSearchFailureReason ); $repairPayload = [ 'results' => $primaryShopResults, 'attemptedRepair' => false, 'usedRepair' => false, 'repairQueries' => [], ]; } else { yield $this->systemMsg($this->agentRunnerConfig->getShopRepairCheckMessage(), 'think'); $repairPayload = $this->repairShopResults( prompt: $prompt, userId: $userId, commerceIntent: $commerceIntent, commerceHistoryContext: $shopQueryHistoryContext, primaryQuery: $shopSearchQuery, primaryShopResults: $primaryShopResults, knowledgeChunks: $knowledgeChunks ); } } $unguardedShopResults = $repairPayload['results']; $shopResults = $this->guardDirectProductShopResults($prompt, $shopSearchQuery, $unguardedShopResults); $directIdentityRepairPayload = $this->repairEmptyDirectProductPrimaryIdentityResults( prompt: $prompt, userId: $userId, commerceIntent: $commerceIntent, shopSearchQuery: $shopSearchQuery, unguardedShopResults: $unguardedShopResults, guardedShopResults: $shopResults ); if ($directIdentityRepairPayload['results'] !== null) { $shopResults = $directIdentityRepairPayload['results']; } $shopResults = $this->guardShopResultsByReferencedProductAnchor($shopSearchQuery, $shopResults); $shopResults = $this->guardShopResultsByExactRequestedAccessoryCode($prompt, $shopSearchQuery, $shopResults); $shopResults = $this->sortShopResultsForLengthRequest($prompt, $shopSearchQuery, $shopResults); $attemptedShopRepair = $repairPayload['attemptedRepair'] || $directIdentityRepairPayload['attemptedRepair']; $usedShopRepair = $repairPayload['usedRepair'] || $directIdentityRepairPayload['usedRepair']; $shopRepairQueries = array_values(array_unique(array_merge( $repairPayload['repairQueries'], $directIdentityRepairPayload['repairQueries'] ))); if (!$primaryShopSearchHadSystemFailure) { yield $this->systemMsg( $this->buildShopSearchMetaMessage( query: $shopSearchDisplayQuery !== '' ? $shopSearchDisplayQuery : $shopSearchQuery, commerceIntent: $commerceIntent, usedOptimizedQuery: $shopSearchUsedOptimizedQuery, originalQuery: $shopSearchQuery, resultCount: count($shopResults), completed: true, attemptedRepair: $attemptedShopRepair, usedRepair: $usedShopRepair ), 'meta' ); } if ($shopResults !== []) { $this->addSource($sources, $this->agentRunnerConfig->getShopSystemSourceLabel()); } if ($attemptedShopRepair) { $this->addSource($sources, $this->agentRunnerConfig->getExtendedShopSearchSourceLabel()); } yield $this->systemMsg( $this->buildProductionUiMetaMessage( stageLabel: $primaryShopSearchHadSystemFailure ? $this->agentRunnerConfig->getProductionUiStageLabel('shop_unavailable') : $this->agentRunnerConfig->getProductionUiStageLabel('shop_completed'), ragCount: count($knowledgeChunks), shopCount: $primaryShopSearchHadSystemFailure ? null : count($shopResults), shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : 'count', sourceLabels: $sources, confidenceLabel: $this->resolveProductionUiConfidenceLabel( hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState), isCommerceIntent: true, shopSearchAttempted: $shopSearchAttempted, hasShopResults: $shopResults !== [], shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, knowledgeEvidenceState: $knowledgeEvidenceState ) ), 'meta' ); } if ($shopResults !== []) { $knowledgeChunks = $this->limitKnowledgeChunks($knowledgeChunks, $commerceIntent); } yield $this->systemMsg($this->agentRunnerConfig->getAnalyzeAllInformationMessage(), 'think'); $finalPrompt = $this->promptBuilder->build( prompt: $prompt, userId: $userId, urlContent: $urlContent, knowledgeChunks: $knowledgeChunks, shopResults: $shopResults, fullContext: $forceFullContext, swagFullOutPut: $optimizedShopQuery, commerceSearchAttempted: $shopSearchAttempted, shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, knowledgeEvidenceState: $knowledgeEvidenceState ); if ($this->debug && $this->logPrompt) { $this->agentLogger->debug('Final prompt', [ 'userId' => $userId, 'finalPrompt' => $finalPrompt, 'optimizedShopQuery' => $optimizedShopQuery, 'shopSearchQuery' => $shopSearchQuery, 'knowledgeRetrievalPrompt' => $knowledgeRetrievalPrompt, 'usedFollowUpRetrievalContext' => $usedFollowUpRetrievalContext, 'primaryShopResultsCount' => count($primaryShopResults), 'shopResultsCount' => count($shopResults), 'attemptedShopRepair' => $attemptedShopRepair, 'usedShopRepair' => $usedShopRepair, 'shopRepairQueries' => $shopRepairQueries, 'shopSearchAttempted' => $shopSearchAttempted, ]); } if ($this->debug && $this->logContext) { $this->agentLogger->debug('Conversation context snapshot', [ 'userId' => $userId, 'context' => $this->contextService->buildUserContext( $userId, $forceFullContext ), ]); } yield $this->systemMsg( $this->buildProductionUiMetaMessage( stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('answer_generating'), ragCount: count($knowledgeChunks), shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null), shopCountMode: $this->resolveShopCountModeForMeta( commerceIntent: $commerceIntent, shopSearchAttempted: $shopSearchAttempted, shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery ), sourceLabels: $sources, confidenceLabel: $this->resolveProductionUiConfidenceLabel( hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState), isCommerceIntent: $this->isCommerceIntent($commerceIntent), shopSearchAttempted: $shopSearchAttempted, hasShopResults: $shopResults !== [], shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, knowledgeEvidenceState: $knowledgeEvidenceState ) ), 'meta' ); if ($sources !== []) { yield $this->emitSources( $sources, $this->agentRunnerConfig->getUsedSourcesPrefix() ); } $noLlmFallbackAnswer = $this->buildNoLlmFallbackAnswer( prompt: $prompt, urlContent: $urlContent, knowledgeChunks: $knowledgeChunks, shopResults: $shopResults, commerceIntent: $commerceIntent, shopSearchAttempted: $shopSearchAttempted, shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, shopSearchFailureReason: $primaryShopSearchFailureReason ?? null, knowledgeEvidenceState: $knowledgeEvidenceState ); $deterministicDirectShopAnswer = $this->buildDeterministicDirectShopResultAnswer( prompt: $prompt, shopResults: $shopResults, commerceIntent: $commerceIntent, shopSearchAttempted: $shopSearchAttempted, shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, shopSearchQuery: $shopSearchQuery ); if ($deterministicDirectShopAnswer !== '') { $fullOutput = $deterministicDirectShopAnswer; yield $this->systemMsg($deterministicDirectShopAnswer, 'answer'); } else { $fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer); } yield $this->systemMsg( $this->buildProductionUiMetaMessage( stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('completed'), ragCount: count($knowledgeChunks), shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null), shopCountMode: $this->resolveShopCountModeForMeta( commerceIntent: $commerceIntent, shopSearchAttempted: $shopSearchAttempted, shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery ), sourceLabels: $sources, confidenceLabel: $this->resolveProductionUiConfidenceLabel( hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState), isCommerceIntent: $this->isCommerceIntent($commerceIntent), shopSearchAttempted: $shopSearchAttempted, hasShopResults: $shopResults !== [], shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, knowledgeEvidenceState: $knowledgeEvidenceState ), completed: true ), 'meta' ); $followUpActionsMessage = $this->buildFollowUpActionsMessage( isCommerceIntent: $this->isCommerceIntent($commerceIntent), hasShopResults: $shopResults !== [], hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState), shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, shopResults: $shopResults, shopSearchQuery: $shopSearchQuery, answerText: $fullOutput ); if ($followUpActionsMessage !== '') { yield $this->systemMsg($followUpActionsMessage, 'meta'); } /* if ($sources !== []) { yield $this->emitSources( $sources, $this->agentRunnerConfig->getSourcesPrefix() ); }*/ if ($this->debug) { yield $this->systemMsg($finalPrompt, 'debug'); } $historyResponse = $this->buildHistoryResponse($fullOutput, $historyNotices); if ($historyResponse !== '') { $this->contextService->appendHistory( $userId, $originalPrompt, $historyResponse ); } $this->agentLogger->info('Agent run finished', [ 'userId' => $userId, 'outputLength' => mb_strlen($fullOutput), 'contextMode' => $forceFullContext ? 'full' : 'recent', 'commerceIntent' => $commerceIntent, 'originalPrompt' => $originalPrompt, 'effectivePrompt' => $prompt, 'routingPrompt' => $routingPrompt, 'promptWasNormalized' => $routingPrompt !== $originalPrompt, 'primaryShopResultsCount' => count($primaryShopResults), 'shopResultsCount' => count($shopResults), 'attemptedShopRepair' => $attemptedShopRepair, 'usedShopRepair' => $usedShopRepair, 'shopRepairQueries' => $shopRepairQueries, 'shopSearchAttempted' => $shopSearchAttempted, 'primaryShopSearchHadSystemFailure' => $primaryShopSearchHadSystemFailure, 'primaryShopSearchFailureReason' => $primaryShopSearchFailureReason ?? null, 'knowledgeChunkCount' => count($knowledgeChunks), 'knowledgeRetrievalPrompt' => $knowledgeRetrievalPrompt, 'usedFollowUpRetrievalContext' => $usedFollowUpRetrievalContext, 'hasUrlContent' => $urlContent !== '', 'usedOptimizedShopQuery' => $optimizedShopQuery !== '', 'optimizedShopQuery' => $optimizedShopQuery, 'shopSearchQuery' => $shopSearchQuery, 'hasCommerceHistoryContext' => $commerceHistoryContext !== '', 'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext), ]); } catch (Throwable $e) { $this->agentLogger->error('Agent run failed', [ 'userId' => $userId, 'exception' => $e, ]); $userErrorMessage = $this->buildUserErrorMessage($e); yield $this->systemMsg( $this->buildProductionUiMetaMessage( stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('interrupted'), ragCount: count($knowledgeChunks), shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null), shopCountMode: $this->resolveShopCountModeForMeta( commerceIntent: $commerceIntent, shopSearchAttempted: $shopSearchAttempted, shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery ), sourceLabels: $sources, confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('interrupted'), completed: true ), 'meta' ); yield $this->systemMsg($userErrorMessage, 'err'); $historyResponse = $this->buildHistoryResponse('', array_merge( $historyNotices, [$this->buildHistoryNotice($this->agentRunnerConfig->getProductionUiText('history_notice_answer_incomplete_title'), $e->getMessage())] )); if ($historyResponse !== '') { $this->contextService->appendHistory($userId, $originalPrompt, $historyResponse); } } } /** * @return Generator */ private function normalizePromptForRouting(string $prompt, string $userId): Generator { if (!$this->agentRunnerConfig->isInputNormalizationEnabled()) { return $prompt; } if ($this->shouldSkipInputNormalization($prompt)) { return $prompt; } $normalizationPrompt = trim($this->agentRunnerConfig->getInputNormalizationPrompt($prompt)); if ($normalizationPrompt === '') { return $prompt; } $candidate = ''; $lastHeartbeatAt = time(); $this->thinkSuppressor->reset(); try { foreach ($this->ollamaClient->stream($normalizationPrompt) as $token) { if (!is_string($token)) { continue; } if (time() - $lastHeartbeatAt >= 2) { yield $this->systemMsg($this->agentRunnerConfig->getInputNormalizationHeartbeatMessage(), 'think'); $lastHeartbeatAt = time(); } $cleanToken = $this->thinkSuppressor->filter($token); if ($cleanToken === '') { continue; } $candidate .= $cleanToken; } } catch (Throwable $e) { $this->agentLogger->warning('Prompt normalization failed, falling back to fuzzy routing-signal normalization', [ 'userId' => $userId, 'exception' => $e, ]); return $this->applyFuzzyRoutingSignalNormalization($prompt, $prompt); } $normalized = $this->sanitizeNormalizedPromptForRouting($candidate, $prompt); return $this->applyFuzzyRoutingSignalNormalization($normalized, $prompt); } private function shouldSkipInputNormalization(string $prompt): bool { if (mb_strlen($prompt, 'UTF-8') > $this->agentRunnerConfig->getInputNormalizationMaxInputChars()) { return true; } foreach ($this->agentRunnerConfig->getInputNormalizationSkipPatterns() as $pattern) { if (@preg_match($pattern, $prompt) === 1) { return true; } } return false; } private function sanitizeNormalizedPromptForRouting(string $candidate, string $originalPrompt): string { $candidate = trim($candidate); if ($candidate === '') { return $originalPrompt; } $candidate = preg_split('/\R{2,}/u', $candidate, 2)[0] ?? $candidate; $candidate = trim($candidate); $candidate = preg_replace($this->agentRunnerConfig->getInputNormalizationOutputPrefixPattern(), '', $candidate) ?? $candidate; $candidate = trim($candidate, $this->agentRunnerConfig->getOptimizedShopQueryTrimCharacters()); $candidate = preg_replace('/\s+/u', ' ', $candidate) ?? $candidate; $candidate = trim($candidate); if ($candidate === '') { return $originalPrompt; } if ($this->isInputNormalizationPlaceholderOutput($candidate)) { return $originalPrompt; } if (mb_strlen($candidate, 'UTF-8') > $this->agentRunnerConfig->getInputNormalizationMaxOutputChars()) { return $originalPrompt; } if ($this->normalizeRoutingComparisonText($candidate) === $this->normalizeRoutingComparisonText($originalPrompt)) { return $originalPrompt; } if (!$this->isSafeNormalizedPromptCandidate($candidate, $originalPrompt)) { return $originalPrompt; } return $candidate; } private function applyFuzzyRoutingSignalNormalization(string $candidate, string $originalPrompt): string { if (!$this->agentRunnerConfig->isInputNormalizationFuzzyRoutingEnabled()) { return $candidate; } $terms = $this->buildFuzzyRoutingTermIndex(); if ($terms === []) { return $candidate; } $minLength = $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMinTokenLength(); $changed = false; $normalized = preg_replace_callback( '/(?resolveFuzzyRoutingTokenReplacement($token, $terms); if ($replacement === null || $replacement === $token) { return $token; } $changed = true; return $replacement; }, $candidate ); if (!is_string($normalized) || !$changed) { return $candidate; } $normalized = preg_replace('/\s+/u', ' ', trim($normalized)) ?? trim($normalized); if ($normalized === '' || $this->normalizeRoutingComparisonText($normalized) === $this->normalizeRoutingComparisonText($candidate)) { return $candidate; } if (!$this->isSafeNormalizedPromptCandidate($normalized, $originalPrompt)) { return $candidate; } return $normalized; } /** * @return array */ private function buildFuzzyRoutingTermIndex(): array { $terms = []; foreach ($this->agentRunnerConfig->getInputNormalizationFuzzyRoutingTerms() as $term) { $term = trim($term); if ($term === '') { continue; } $normalized = $this->normalizeFuzzyRoutingToken($term); if ($normalized === '') { continue; } $terms[$normalized] ??= mb_strtolower($term, 'UTF-8'); } return $terms; } /** * @param array $terms */ private function resolveFuzzyRoutingTokenReplacement(string $token, array $terms): ?string { $normalizedToken = $this->normalizeFuzzyRoutingToken($token); if ($normalizedToken === '' || isset($terms[$normalizedToken])) { return null; } $bestTerm = null; $bestDistance = PHP_INT_MAX; $ambiguous = false; $tokenLength = max(1, strlen($normalizedToken)); foreach ($terms as $normalizedTerm => $term) { $termLength = strlen($normalizedTerm); if (abs($tokenLength - $termLength) > $this->resolveFuzzyRoutingMaxDistance(max($tokenLength, $termLength))) { continue; } $distance = $this->calculateFuzzyRoutingDistance($normalizedToken, $normalizedTerm); $maxLength = max($tokenLength, $termLength); $maxDistance = $this->resolveFuzzyRoutingMaxDistance($maxLength); if ($distance > $maxDistance) { continue; } $similarityPercent = (int) round((1 - ($distance / max(1, $maxLength))) * 100); if ($similarityPercent < $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMinSimilarityPercent()) { continue; } if ($distance < $bestDistance) { $bestDistance = $distance; $bestTerm = $term; $ambiguous = false; continue; } if ($distance === $bestDistance && $term !== $bestTerm) { $ambiguous = true; } } if ($bestTerm === null || $ambiguous) { return null; } return $bestTerm; } private function calculateFuzzyRoutingDistance(string $left, string $right): int { $leftLength = strlen($left); $rightLength = strlen($right); if ($leftLength === 0) { return $rightLength; } if ($rightLength === 0) { return $leftLength; } $distance = []; for ($i = 0; $i <= $leftLength; $i++) { $distance[$i] = [$i]; } for ($j = 0; $j <= $rightLength; $j++) { $distance[0][$j] = $j; } for ($i = 1; $i <= $leftLength; $i++) { for ($j = 1; $j <= $rightLength; $j++) { $cost = $left[$i - 1] === $right[$j - 1] ? 0 : 1; $distance[$i][$j] = min( $distance[$i - 1][$j] + 1, $distance[$i][$j - 1] + 1, $distance[$i - 1][$j - 1] + $cost ); if ( $i > 1 && $j > 1 && $left[$i - 1] === $right[$j - 2] && $left[$i - 2] === $right[$j - 1] ) { $distance[$i][$j] = min($distance[$i][$j], $distance[$i - 2][$j - 2] + 1); } } } return $distance[$leftLength][$rightLength]; } private function resolveFuzzyRoutingMaxDistance(int $tokenLength): int { if ($tokenLength >= $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingLongTokenLength()) { return $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMaxDistanceLong(); } if ($tokenLength >= $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMediumTokenLength()) { return $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMaxDistanceMedium(); } return $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMaxDistanceShort(); } private function normalizeFuzzyRoutingToken(string $token): string { $token = mb_strtolower(trim($token), 'UTF-8'); $token = $this->languageCleanupConfig->transliterateToAscii($token); $token = preg_replace('/[^a-z0-9]+/u', '', $token) ?? $token; return trim($token); } private function isSafeNormalizedPromptCandidate(string $candidate, string $originalPrompt): bool { $originalLength = max(1, mb_strlen($originalPrompt, 'UTF-8')); $candidateLength = mb_strlen($candidate, 'UTF-8'); $maxLength = (int) ceil($originalLength * ($this->agentRunnerConfig->getInputNormalizationMaxLengthRatioPercent() / 100)); if ($candidateLength > $maxLength) { return false; } $originalTokens = $this->tokenizeInputNormalizationGuardText($originalPrompt); $candidateTokens = $this->tokenizeInputNormalizationGuardText($candidate); $maxAddedTokens = $this->agentRunnerConfig->getInputNormalizationMaxAddedTokens(); if (count($candidateTokens) > count($originalTokens) + $maxAddedTokens) { return false; } $originalNumbers = $this->extractInputNormalizationNumbers($originalPrompt); foreach ($this->extractInputNormalizationNumbers($candidate) as $number) { if (!in_array($number, $originalNumbers, true)) { return false; } } return true; } private function isInputNormalizationPlaceholderOutput(string $candidate): bool { $normalized = $this->normalizeRoutingComparisonText($candidate); return in_array($normalized, array_map( fn (string $placeholder): string => $this->normalizeRoutingComparisonText($placeholder), $this->agentRunnerConfig->getInputNormalizationPlaceholderOutputs() ), true); } private function normalizeRoutingComparisonText(string $value): string { $value = mb_strtolower(trim($value), 'UTF-8'); $value = preg_replace('/\s+/u', ' ', $value) ?? $value; return trim($value); } /** * @return string[] */ private function tokenizeInputNormalizationGuardText(string $value): array { if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', mb_strtolower($value, 'UTF-8'), $matches) === false) { return []; } return array_values(array_filter( array_map(static fn(string $token): string => trim($token), $matches[0] ?? []), static fn(string $token): bool => $token !== '' )); } /** * @return string[] */ private function extractInputNormalizationNumbers(string $value): array { if (preg_match_all('/\d+(?:[,.]\d+)?/u', $value, $matches) === false) { return []; } return array_values(array_unique(array_map( static fn(string $number): string => str_replace(',', '.', $number), $matches[0] ?? [] ))); } private function detectCommerceIntent(string $prompt): string { $commerceMeta = $this->commerceIntentLite->detect($prompt); return (string) ($commerceMeta['intent'] ?? CommerceIntentLite::NONE); } private function detectCommerceIntentForRouting( string $prompt, string $userId, string $requestContextHint ): string { $commerceIntent = $this->detectCommerceIntent($prompt); if ($this->isCommerceIntent($commerceIntent)) { return $commerceIntent; } if (!$this->isCommercialTableFollowUpPrompt($prompt)) { return $commerceIntent; } $this->agentLogger->info('Promoted commercial table follow-up to shop intent', [ 'userId' => $userId, 'prompt' => $prompt, 'hasRequestContextHint' => trim($requestContextHint) !== '', ]); return CommerceIntentLite::PRODUCT_SEARCH; } private function isCommerceIntent(string $commerceIntent): bool { return $commerceIntent === CommerceIntentLite::PRODUCT_SEARCH || $commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH; } private function buildKnowledgeRetrievalPrompt( string $prompt, string $userId, string $commerceIntent ): string { if (!$this->shouldUseFollowUpContextForKnowledgeRetrieval($prompt, $commerceIntent)) { return $prompt; } $history = $this->contextService->buildUserContextWithinBudget($userId, 3000); $previousQuestions = $this->extractRecentUserQuestions($history, 2); $referenceAnchors = $this->referenceAnchorExtractor->extractLatestAssistantReferenceAnchors($history); if ($previousQuestions === [] && $referenceAnchors === []) { return $prompt; } $lines = []; foreach ($previousQuestions as $question) { $lines[] = $this->renderAgentTemplate($this->agentRunnerConfig->getFollowUpContextPreviousUserQuestionTemplate(), ['question' => $question]); } if ($referenceAnchors !== []) { $lines[] = 'Vorherige technische Referenzanker (nur zur Referenzauflösung, keine Faktenquelle): ' . implode(' ', $referenceAnchors); } $lines[] = $this->renderAgentTemplate($this->agentRunnerConfig->getFollowUpContextCurrentQuestionTemplate(), ['question' => $prompt]); return implode("\n", $lines); } private function shouldUseFollowUpContextForKnowledgeRetrieval(string $prompt, string $commerceIntent): bool { if ($this->isCommerceIntent($commerceIntent)) { return false; } $normalized = $this->normalizeFollowUpText($prompt); if ($normalized === '') { return false; } if ($this->containsExplicitCommercialFollowUpSignal($normalized)) { return false; } if (mb_strlen($normalized, 'UTF-8') > 180 && !$this->containsStrongFollowUpReference($normalized)) { return false; } return $this->containsStrongFollowUpReference($normalized); } private function containsStrongFollowUpReference(string $normalized): bool { foreach ($this->agentRunnerConfig->getFollowUpStrongReferencePatterns() as $pattern) { if (preg_match($pattern, $normalized) === 1) { return true; } } return false; } private function containsExplicitCommercialFollowUpSignal(string $normalized): bool { foreach ($this->agentRunnerConfig->getFollowUpExplicitCommercialSignalTerms() as $signal) { if (str_contains($normalized, mb_strtolower($signal, 'UTF-8'))) { return true; } } return false; } /** * @return string[] */ private function extractRecentUserQuestions(string $history, int $limit): array { $history = trim($history); if ($history === '' || $limit <= 0) { return []; } if (preg_match_all($this->agentRunnerConfig->getFollowUpHistoryQuestionPattern(), $history, $matches) === false) { return []; } $questions = array_values(array_filter( array_map( fn(string $question): string => $this->sanitizeHistoryQuestion($question), $matches[1] ?? [] ), static fn(string $question): bool => $question !== '' )); if ($questions === []) { return []; } return array_slice($questions, -$limit); } /** * @return string[] */ private function extractHistoryTurnsNewestFirst(string $history): array { $history = trim($history); if ($history === '') { return []; } $parts = preg_split($this->agentRunnerConfig->getFollowUpHistoryTurnSplitPattern(), $history); if ($parts === false || $parts === []) { return []; } $turns = array_values(array_filter( array_map(static fn(string $part): string => trim($part), $parts), static fn(string $part): bool => $part !== '' )); return array_reverse($turns); } private function sanitizeHistoryQuestion(string $question): string { $question = trim((string) preg_replace('/\s+/u', ' ', $question)); if ($question === '') { return ''; } if (mb_strlen($question, 'UTF-8') <= 500) { return $question; } return rtrim(mb_substr($question, 0, 497, 'UTF-8')) . '...'; } private function normalizeFollowUpText(string $value): string { $value = mb_strtolower(trim($value), 'UTF-8'); $value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value); $value = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value; $value = preg_replace('/\s+/u', ' ', $value) ?? $value; return trim($value); } /** * @return Generator */ private function buildOptimizedShopQuery( string $prompt, string $userId, string $commerceHistoryContext = '' ): Generator { $shopPrompt = trim($this->agentRunnerConfig->getShopPrompt( $prompt, $commerceHistoryContext )); if ($shopPrompt === '') { return ''; } $optimizedQuery = ''; $lastHeartbeatAt = time(); $this->thinkSuppressor->reset(); try { foreach ($this->ollamaClient->stream($shopPrompt) as $token) { if (!is_string($token)) { continue; } if (time() - $lastHeartbeatAt >= 2) { yield $this->systemMsg($this->agentRunnerConfig->getShopQueryOptimizationHeartbeatMessage(), 'think'); $lastHeartbeatAt = time(); } $cleanToken = $this->thinkSuppressor->filter($token); if ($cleanToken === '') { continue; } $optimizedQuery .= $cleanToken; } } catch (Throwable $e) { $this->agentLogger->warning('Shop query optimization failed, falling back to original prompt', [ 'userId' => $userId, 'exception' => $e, ]); return ''; } return $this->sanitizeOptimizedShopQuery($optimizedQuery, $prompt, $commerceHistoryContext); } /** * @return array{ * results: array, * attemptedRepair: bool, * usedRepair: bool, * repairQueries: string[] * } */ private function repairShopResults( string $prompt, string $userId, string $commerceIntent, string $commerceHistoryContext, string $primaryQuery, array $primaryShopResults, array $knowledgeChunks ): array { try { return $this->searchRepairService->repair( prompt: $prompt, commerceIntent: $commerceIntent, commerceHistoryContext: $commerceHistoryContext, primaryQuery: $primaryQuery, primaryShopResults: $primaryShopResults, knowledgeChunks: $knowledgeChunks ); } catch (Throwable $e) { $this->agentLogger->warning('Shop repair failed, continuing with primary shop results', [ 'userId' => $userId, 'commerceIntent' => $commerceIntent, 'primaryQuery' => $primaryQuery, 'primaryShopResultsCount' => count($primaryShopResults), 'exception' => $e, ]); return [ 'results' => $primaryShopResults, 'attemptedRepair' => false, 'usedRepair' => false, 'repairQueries' => [], ]; } } private function resolveShopQueryHistoryContext(string $prompt, string $commerceHistoryContext): string { $commerceHistoryContext = trim($commerceHistoryContext); if ($commerceHistoryContext === '') { return ''; } if ($this->shouldUseCommerceHistoryForShopQuery($prompt)) { return $commerceHistoryContext; } return ''; } private function shouldUseCommerceHistoryForShopQuery(string $prompt): bool { $prompt = trim($prompt); if ($prompt === '') { return false; } if ($this->isCommercialTableFollowUpPrompt($prompt)) { return true; } if ($this->isMetaOnlyShopQuery($prompt)) { return true; } if ($this->referenceAnchorExtractor->extractFirstProductModelAnchor($prompt) !== '') { return false; } $normalizedPrompt = $this->normalizeFollowUpText($prompt); if ($this->containsConfiguredShopQueryAnchorTrigger($normalizedPrompt)) { return !$this->containsNumericShopQueryToken($normalizedPrompt); } return $this->containsReferentialShopQueryMarker($normalizedPrompt); } private function containsNumericShopQueryToken(string $text): bool { return preg_match('/\d/u', $text) === 1; } private function containsReferentialShopQueryMarker(string $text): bool { $tokens = $this->tokenizeShopQueryCandidate($text); if ($tokens === []) { return false; } $tokenSet = array_fill_keys($tokens, true); foreach ($this->agentRunnerConfig->getShopQueryContextUsageReferentialTerms() as $term) { foreach ($this->tokenizeShopQueryCandidate($term) as $termToken) { if (isset($tokenSet[$termToken])) { return true; } } } return false; } private function shouldIsolateStandaloneShopQueryFromHistory(string $prompt): bool { $prompt = trim($prompt); if ($prompt === '') { return false; } if ($this->isCommercialTableFollowUpPrompt($prompt) || $this->isMetaOnlyShopQuery($prompt)) { return false; } $normalizedPrompt = $this->normalizeFollowUpText($prompt); $usesReferenceLanguage = $this->containsReferentialShopQueryMarker($normalizedPrompt) || $this->containsConfiguredShopQueryAnchorTrigger($normalizedPrompt); if (!$usesReferenceLanguage) { return true; } return $this->hasStandaloneConcreteShopSubject($prompt); } private function hasStandaloneConcreteShopSubject(string $prompt): bool { if ($this->referenceAnchorExtractor->extractFirstProductModelAnchor($prompt) !== '') { return true; } $contextFallbackQuery = $this->buildContextFallbackShopQuery($prompt); $tokens = $this->tokenizeShopQueryCandidate($contextFallbackQuery); if (count($tokens) >= 2) { return true; } foreach ($tokens as $token) { if (preg_match('/\d/u', $token) === 1) { return true; } } return false; } private function shouldUseDeterministicStandaloneShopQuery(string $prompt, string $shopQueryHistoryContext): bool { $prompt = trim($prompt); if ($prompt === '') { return false; } if (trim($shopQueryHistoryContext) !== '') { return false; } if ($this->isCommercialTableFollowUpPrompt($prompt)) { return false; } if ($this->isMetaOnlyShopQuery($prompt)) { return false; } return true; } private function guardStandaloneOptimizedShopQuery(string $prompt, string $optimizedShopQuery): string { if ($this->shouldUseCommerceHistoryForShopQuery($prompt)) { return $optimizedShopQuery; } if ($this->standaloneOptimizedShopQueryIntroducesUnsupportedContext($prompt, $optimizedShopQuery)) { $this->agentLogger->info('Ignored optimized shop query because it introduced unsupported standalone context', [ 'prompt' => $prompt, 'optimizedShopQuery' => $optimizedShopQuery, ]); return $prompt; } if ($this->referenceAnchorExtractor->extractFirstProductModelAnchor($prompt) === '') { return $optimizedShopQuery; } if (!$this->containsConfiguredShopQueryAnchorTrigger($optimizedShopQuery)) { return $optimizedShopQuery; } if ($this->containsConfiguredShopQueryAnchorTrigger($prompt)) { return $optimizedShopQuery; } $this->agentLogger->info('Ignored optimized shop query because it added an unsupported context anchor', [ 'prompt' => $prompt, 'optimizedShopQuery' => $optimizedShopQuery, ]); return $prompt; } private function guardFinalStandaloneShopSearchQuery(string $prompt, string $shopSearchQuery): string { $shopSearchQuery = trim($shopSearchQuery); if ($shopSearchQuery === '') { return ''; } $guardedQuery = $this->guardStandaloneOptimizedShopQuery($prompt, $shopSearchQuery); $query = $guardedQuery !== $shopSearchQuery ? $this->preserveCurrentInputShopQueryTerms($prompt, $guardedQuery) : $this->preserveCurrentInputShopQueryTerms($prompt, $shopSearchQuery); $query = $this->cleanupDirectProductAttributeShopQuery($prompt, $query); $query = $this->applyConfiguredShopSearchTokenCorrections($query); return $this->cleanupShopQueryStopwords($query); } private function cleanupShopQueryStopwords(string $shopSearchQuery): string { $shopSearchQuery = trim($shopSearchQuery); if ( $shopSearchQuery === '' || !$this->agentRunnerConfig->isShopQueryStopwordCleanupEnabled() ) { return $shopSearchQuery; } $removeTokens = []; foreach ($this->agentRunnerConfig->getShopQueryStopwordCleanupTerms() as $term) { foreach ($this->tokenizeShopQueryCandidate($term) as $token) { $removeTokens[$token] = true; } } if ($removeTokens === []) { return $shopSearchQuery; } $kept = []; foreach ($this->tokenizeShopQueryCandidate($shopSearchQuery) as $token) { if (isset($removeTokens[$token]) || isset($kept[$token])) { continue; } $kept[$token] = $token; } if (count($kept) < max(1, $this->agentRunnerConfig->getShopQueryStopwordCleanupMinTokens())) { return $shopSearchQuery; } $cleaned = implode(' ', array_values($kept)); return $cleaned !== '' ? $cleaned : $shopSearchQuery; } private function enrichGenericDeviceShopQueryWithConfiguredAnchor(string $shopSearchQuery): string { $shopSearchQuery = trim($shopSearchQuery); if ( $shopSearchQuery === '' || !$this->agentRunnerConfig->isGenericDeviceQueryAnchorEnabled() ) { return $shopSearchQuery; } $tokens = $this->tokenizeShopQueryCandidate($shopSearchQuery); if ($tokens === []) { return $shopSearchQuery; } $tokenSet = array_fill_keys($tokens, true); $genericDeviceTokens = $this->buildShopQueryTokenSet( $this->agentRunnerConfig->getGenericDeviceQueryAnchorTriggerTerms() ); if (!$this->tokenSetIntersects($tokenSet, $genericDeviceTokens)) { return $shopSearchQuery; } $suppressTokens = $this->buildShopQueryTokenSet( $this->agentRunnerConfig->getGenericDeviceQueryAnchorSuppressTerms() ); if ($this->tokenSetIntersects($tokenSet, $suppressTokens)) { return $shopSearchQuery; } foreach ($this->agentRunnerConfig->getGenericDeviceQueryAnchorRules() as $rule) { $anchor = $rule['anchor']; if ($anchor === '' || !$this->containsAnyShopQueryTerm($shopSearchQuery, $rule['match_terms'])) { continue; } if ($this->queryAlreadyContainsAllAnchorTokens($shopSearchQuery, $anchor)) { return $shopSearchQuery; } $query = $this->agentRunnerConfig->shouldGenericDeviceQueryAnchorRemoveGenericDeviceTerms() ? $this->removeConfiguredGenericDeviceShopQueryTerms( $shopSearchQuery, $this->agentRunnerConfig->getGenericDeviceQueryAnchorTriggerTerms() ) : $shopSearchQuery; $template = $this->agentRunnerConfig->getGenericDeviceQueryAnchorTemplate(); if ($template === '') { return $shopSearchQuery; } $enriched = $this->renderAgentTemplate($template, [ 'anchor' => $anchor, 'query' => $query, ]); $enriched = preg_replace('/\s+/u', ' ', $enriched) ?? $enriched; $enriched = trim($enriched); return $enriched !== '' ? $enriched : $shopSearchQuery; } return $shopSearchQuery; } /** * @param string[] $genericDeviceTerms */ private function removeConfiguredGenericDeviceShopQueryTerms(string $shopSearchQuery, array $genericDeviceTerms): string { $removeTokens = $this->buildShopQueryTokenSet($genericDeviceTerms); if ($removeTokens === []) { return $shopSearchQuery; } $kept = []; foreach ($this->tokenizeShopQueryCandidate($shopSearchQuery) as $token) { if (isset($removeTokens[$token]) || isset($kept[$token])) { continue; } $kept[$token] = $token; } $cleaned = implode(' ', array_values($kept)); return $cleaned !== '' ? $cleaned : $shopSearchQuery; } private function filterShopQueryToPositiveTokens(string $shopSearchQuery): string { $shopSearchQuery = trim($shopSearchQuery); if ( $shopSearchQuery === '' || !$this->agentRunnerConfig->isShopQueryPositiveTokenFilterEnabled() ) { return $shopSearchQuery; } $tokens = $this->tokenizeShopQueryCandidate($shopSearchQuery); if ($tokens === []) { return $shopSearchQuery; } $allowedTokens = $this->buildPositiveShopQueryAllowedTokenSet(); $blockedTokens = $this->buildPositiveShopQueryBlockedTokenSet(); $codePatterns = $this->agentRunnerConfig->getShopQueryPositiveTokenFilterCodePatterns(); $adjacentVariantPatterns = $this->agentRunnerConfig->getShopQueryPositiveTokenFilterAdjacentVariantPatterns(); $adjacentVariantTokens = $this->buildShopQueryTokenSet( $this->agentRunnerConfig->getShopQueryPositiveTokenFilterAdjacentVariantTerms() ); if ($allowedTokens === [] && $codePatterns === [] && $adjacentVariantPatterns === [] && $adjacentVariantTokens === []) { return $shopSearchQuery; } $kept = []; foreach ($tokens as $token) { if (isset($blockedTokens[$token]) || isset($kept[$token])) { continue; } if (isset($allowedTokens[$token]) || $this->matchesAnyConfiguredShopQueryCodePattern($token, $codePatterns)) { $kept[$token] = $token; } } foreach ($tokens as $index => $token) { if (isset($blockedTokens[$token]) || isset($kept[$token])) { continue; } if ($this->shouldKeepAdjacentVariantShopQueryToken($token, $index, $tokens, $kept, $adjacentVariantPatterns, $adjacentVariantTokens)) { $kept[$token] = $token; } } if (count($kept) < max(1, $this->agentRunnerConfig->getShopQueryPositiveTokenFilterMinTokens())) { return $shopSearchQuery; } $filteredTokens = []; foreach ($tokens as $token) { if (isset($kept[$token])) { $filteredTokens[] = $token; } } $filtered = implode(' ', $filteredTokens); return $filtered !== '' ? $filtered : $shopSearchQuery; } /** * @param string[] $tokens * @param array $kept * @param string[] $variantPatterns * @param array $variantTokens */ private function shouldKeepAdjacentVariantShopQueryToken( string $token, int $index, array $tokens, array $kept, array $variantPatterns, array $variantTokens ): bool { $isExplicitVariantToken = isset($variantTokens[$token]); $isPatternVariantToken = $this->matchesAnyConfiguredShopQueryCodePattern($token, $variantPatterns); if (!$isExplicitVariantToken && !$isPatternVariantToken) { return false; } $hasAdjacentNumericContext = false; $hasAdjacentExplicitVariantContext = false; $nearbyKeptContextCount = 0; for ($offset = -2; $offset <= 2; ++$offset) { if ($offset === 0) { continue; } $nearbyIndex = $index + $offset; if (!isset($tokens[$nearbyIndex])) { continue; } $nearbyToken = $tokens[$nearbyIndex]; if (isset($kept[$nearbyToken])) { ++$nearbyKeptContextCount; } if (abs($offset) === 1 && preg_match('/\d/u', $nearbyToken) === 1) { $hasAdjacentNumericContext = true; } if (abs($offset) === 1 && isset($variantTokens[$nearbyToken])) { $hasAdjacentExplicitVariantContext = true; } } if ($hasAdjacentNumericContext && $nearbyKeptContextCount >= 2) { return true; } // Preserve compact all-alpha model/acronym chains such as // "Testomat LAB CL" without allowing arbitrary descriptive words to // pass the positive token filter. The non-numeric path therefore uses // only explicitly configured neighbouring variant terms from YAML. return $isExplicitVariantToken && $hasAdjacentExplicitVariantContext && $nearbyKeptContextCount >= 1; } /** * @return array */ private function buildPositiveShopQueryAllowedTokenSet(): array { $terms = $this->agentRunnerConfig->getShopQueryPositiveTokenFilterAllowedTerms(); if ($this->agentRunnerConfig->shouldShopQueryPositiveTokenFilterIncludeCurrentInputPreservationTerms()) { $terms = $this->mergeUniqueStrings( $terms, $this->agentRunnerConfig->getShopQueryCurrentInputPreservationTerms() ); } if ($this->agentRunnerConfig->shouldShopQueryPositiveTokenFilterIncludeSemanticShopSearchTokens()) { $terms = $this->mergeUniqueStrings( $terms, $this->agentRunnerConfig->getShopQueryPositiveTokenFilterSemanticShopSearchTokens() ); } if ($this->agentRunnerConfig->shouldShopQueryPositiveTokenFilterIncludeProductRoleTerms()) { $terms = $this->mergeUniqueStrings( $terms, $this->agentRunnerConfig->getShopQueryPositiveTokenFilterProductRoleTerms() ); } $terms = $this->mergeUniqueStrings( $terms, $this->agentRunnerConfig->getGenericDeviceQueryAnchorPositiveFilterTerms() ); $tokens = []; foreach ($terms as $term) { foreach ($this->tokenizeShopQueryCandidate($term) as $token) { $tokens[$token] = true; } } return $tokens; } /** * @return array */ private function buildPositiveShopQueryBlockedTokenSet(): array { $tokens = []; foreach ($this->agentRunnerConfig->getShopQueryPositiveTokenFilterBlockedTerms() as $term) { foreach ($this->tokenizeShopQueryCandidate($term) as $token) { $tokens[$token] = true; } } return $tokens; } /** * @param string[] $patterns */ private function matchesAnyConfiguredShopQueryCodePattern(string $token, array $patterns): bool { foreach ($patterns as $pattern) { if (@preg_match($pattern, $token) === 1) { return true; } } return false; } private function cleanupDirectProductAttributeShopQuery(string $prompt, string $shopSearchQuery): string { $shopSearchQuery = trim($shopSearchQuery); if ( $shopSearchQuery === '' || !$this->agentRunnerConfig->isShopQueryProductAttributeCleanupEnabled() ) { return $shopSearchQuery; } $combined = trim($prompt . ' ' . $shopSearchQuery); if (!$this->containsAnyShopQueryTerm($combined, $this->agentRunnerConfig->getShopQueryProductAttributeCleanupProductTypeTerms())) { return $shopSearchQuery; } $constraintTokens = $this->extractConfiguredShopQueryConstraintTokens( $combined, $this->agentRunnerConfig->getShopQueryProductAttributeCleanupComparativeConstraintPatterns() ); if ($constraintTokens === []) { return $shopSearchQuery; } $removeTokens = array_fill_keys($constraintTokens, true); foreach ($this->agentRunnerConfig->getShopQueryProductAttributeCleanupStopTerms() as $term) { foreach ($this->tokenizeShopQueryCandidate($term) as $token) { $removeTokens[$token] = true; } } $kept = []; foreach ($this->tokenizeShopQueryCandidate($shopSearchQuery) as $token) { if (isset($removeTokens[$token]) || isset($kept[$token])) { continue; } $kept[$token] = $token; } if (count($kept) < max(1, $this->agentRunnerConfig->getShopQueryProductAttributeCleanupMinTokens())) { return $shopSearchQuery; } $cleaned = implode(' ', array_values($kept)); return $cleaned !== '' ? $cleaned : $shopSearchQuery; } /** * @param string[] $terms */ private function containsAnyShopQueryTerm(string $text, array $terms): bool { $tokens = array_fill_keys($this->tokenizeShopQueryCandidate($text), true); if ($tokens === []) { return false; } foreach ($terms as $term) { $termTokens = $this->tokenizeShopQueryCandidate($term); if ($termTokens === []) { continue; } $matches = true; foreach ($termTokens as $termToken) { if (!isset($tokens[$termToken])) { $matches = false; break; } } if ($matches) { return true; } } return false; } /** * @param string[] $patterns * @return string[] */ private function extractConfiguredShopQueryConstraintTokens(string $text, array $patterns): array { $tokens = []; foreach ($patterns as $pattern) { if (@preg_match_all($pattern, $text, $matches, PREG_SET_ORDER) === false) { continue; } foreach ($matches as $match) { $value = $match['value'] ?? ($match[1] ?? ''); if (!is_scalar($value)) { continue; } foreach ($this->tokenizeShopQueryCandidate((string) $value) as $token) { $tokens[$token] = $token; } } } return array_values($tokens); } private function preserveCurrentInputShopQueryTerms(string $prompt, string $shopSearchQuery): string { $shopSearchQuery = trim($shopSearchQuery); if ($shopSearchQuery === '' || !$this->agentRunnerConfig->isShopQueryCurrentInputPreservationEnabled()) { return $this->applyConfiguredShopSearchTokenCorrections($shopSearchQuery); } $correctedPrompt = $this->applyConfiguredShopSearchTokenCorrections($prompt); $correctedShopSearchQuery = $this->applyConfiguredShopSearchTokenCorrections($shopSearchQuery); $promptTokens = array_fill_keys($this->tokenizeShopQueryCandidate($correctedPrompt), true); $queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($correctedShopSearchQuery), true); if ($promptTokens === [] || $queryTokens === []) { return $correctedShopSearchQuery; } $appendTokens = []; $preservationTerms = $this->mergeUniqueStrings( $this->languageCleanupConfig->getProtectedTerms(), $this->agentRunnerConfig->getShopQueryCurrentInputPreservationTerms() ); foreach ($preservationTerms as $term) { $termTokens = $this->tokenizeShopQueryCandidate($term); if ($termTokens === []) { continue; } foreach ($termTokens as $termToken) { if (!isset($promptTokens[$termToken]) || isset($queryTokens[$termToken])) { continue; } $appendTokens[$termToken] = $termToken; $queryTokens[$termToken] = true; } } if ($appendTokens === []) { return $correctedShopSearchQuery; } return trim($correctedShopSearchQuery . ' ' . implode(' ', array_values($appendTokens))); } private function applyConfiguredShopSearchTokenCorrections(string $text): string { $text = trim($text); if ($text === '') { return ''; } foreach ($this->commerceQueryParserConfig->getSearchTokenCorrections() as $from => $to) { $from = trim((string) $from); $to = trim((string) $to); if ($from === '' || $to === '') { continue; } $text = preg_replace( '/\b' . preg_quote($from, '/') . '\b/u', $to, $text ) ?? $text; } $text = preg_replace('/\s+/u', ' ', $text) ?? $text; return trim($text); } /** * @param string[] $knowledgeChunks */ private function enrichShopSearchQueryWithRagAnchor( string $prompt, string $shopSearchQuery, array $knowledgeChunks ): string { $shopSearchQuery = trim($shopSearchQuery); if ( $shopSearchQuery === '' || $knowledgeChunks === [] || !$this->agentRunnerConfig->isShopQueryRagAnchorEnrichmentEnabled() ) { return $shopSearchQuery; } $focuses = $this->extractShopQueryNumericFocuses($prompt); if ($focuses === []) { return $shopSearchQuery; } $anchor = $this->resolveBestRagShopQueryAnchor($knowledgeChunks, $focuses); if ($anchor === '') { return $shopSearchQuery; } $queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($shopSearchQuery), true); $anchorTokens = $this->tokenizeShopQueryCandidate($anchor); if ($anchorTokens === []) { return $shopSearchQuery; } $missingAnchorToken = false; foreach ($anchorTokens as $anchorToken) { if (!isset($queryTokens[$anchorToken])) { $missingAnchorToken = true; break; } } if (!$missingAnchorToken) { return $shopSearchQuery; } $subject = $this->extractRagAnchorSubjectTerms($prompt, $shopSearchQuery); $rendered = strtr($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentTemplate(), [ '{anchor}' => $anchor, '{query}' => $shopSearchQuery, '{subject}' => $subject, ]); $enrichedQuery = $this->limitShopQueryTerms( $rendered, $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentMaxQueryTerms() ); return $enrichedQuery !== '' ? $enrichedQuery : $shopSearchQuery; } /** * @return array */ private function extractShopQueryNumericFocuses(string $prompt): array { $focuses = []; $seen = []; foreach ($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentNumericFocusPatterns() as $pattern) { if (@preg_match_all($pattern, $prompt, $matches, PREG_SET_ORDER) === false) { continue; } foreach ($matches as $match) { $rawValue = $match['value'] ?? ($match[1] ?? ''); $rawUnit = $match['unit'] ?? ($match[2] ?? ''); if (!is_scalar($rawValue) || !is_scalar($rawUnit)) { continue; } $value = $this->normalizeShopQueryNumericFocusValue((string) $rawValue); $unit = $this->normalizeShopQueryNumericFocusUnit((string) $rawUnit); if ($value === '' || $unit === '') { continue; } $key = $value . '|' . $unit; if (isset($seen[$key])) { continue; } $seen[$key] = true; $focuses[] = [ 'value' => $value, 'unit' => $unit, ]; } } return $focuses; } /** * @param string[] $knowledgeChunks * @param array $focuses */ private function resolveBestRagShopQueryAnchor(array $knowledgeChunks, array $focuses): string { $bestAnchor = ''; $bestScore = 0; $minScore = $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentMinScore(); $earlyBonusMax = max(0, $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentEarlyChunkBonusMax()); foreach (array_values($knowledgeChunks) as $index => $chunk) { $chunk = (string) $chunk; $anchor = $this->extractRagProductTitleAnchor($chunk); if ($anchor === '') { continue; } $score = $this->scoreRagChunkForShopQueryNumericFocus($chunk, $focuses); if ($score <= 0) { continue; } if ($this->ragAnchorMatchesAnyBonusPattern($anchor)) { $score += $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentAnchorBonusScore(); } if ($earlyBonusMax > 0) { $score += max(0, $earlyBonusMax - min($earlyBonusMax, $index)); } if ($score < $minScore || $score <= $bestScore) { continue; } $bestScore = $score; $bestAnchor = $anchor; } return $bestAnchor; } private function extractRagProductTitleAnchor(string $chunk): string { foreach ($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentProductTitlePatterns() as $pattern) { if (@preg_match($pattern, $chunk, $matches) !== 1) { continue; } $title = $matches['title'] ?? ($matches[1] ?? ''); if (!is_scalar($title)) { continue; } $title = trim(preg_replace('/\s+/u', ' ', str_replace('®', '', (string) $title)) ?? ''); if ($title !== '') { return $title; } } return ''; } /** * @param array $focuses */ private function scoreRagChunkForShopQueryNumericFocus(string $chunk, array $focuses): int { $normalizedChunk = $this->normalizeShopQueryNumericFocusSearchText($chunk); if ($normalizedChunk === '') { return 0; } $score = 0; foreach ($focuses as $focus) { $hasValue = $focus['value'] !== '' && str_contains($normalizedChunk, $focus['value']); $hasUnit = $focus['unit'] === '' || str_contains($normalizedChunk, $focus['unit']); if ($hasValue && $hasUnit) { $score += $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentExactValueUnitScore(); continue; } if ($hasValue) { $score += $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentExactValueScore(); } } return $score; } private function ragAnchorMatchesAnyBonusPattern(string $anchor): bool { foreach ($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentAnchorBonusPatterns() as $pattern) { if (@preg_match($pattern, $anchor) === 1) { return true; } } return false; } private function extractRagAnchorSubjectTerms(string $prompt, string $shopSearchQuery): string { $promptTokens = array_fill_keys($this->tokenizeShopQueryCandidate($prompt), true); $queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($shopSearchQuery), true); $subjectTerms = []; foreach ($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentSubjectTerms() as $term) { $termTokens = $this->tokenizeShopQueryCandidate($term); if ($termTokens === []) { continue; } $allPresent = true; foreach ($termTokens as $termToken) { if (!isset($promptTokens[$termToken])) { $allPresent = false; break; } } if (!$allPresent) { continue; } $alreadyInQuery = true; foreach ($termTokens as $termToken) { if (!isset($queryTokens[$termToken])) { $alreadyInQuery = false; break; } } if (!$alreadyInQuery) { $subjectTerms[] = $term; } } return implode(' ', array_values(array_unique($subjectTerms))); } private function limitShopQueryTerms(string $query, int $maxTerms): string { $maxTerms = max(1, $maxTerms); $tokens = []; foreach ($this->tokenizeShopQueryCandidate($query) as $token) { if (isset($tokens[$token])) { continue; } $tokens[$token] = $token; if (count($tokens) >= $maxTerms) { break; } } return implode(' ', array_values($tokens)); } private function normalizeShopQueryNumericFocusValue(string $value): string { $value = $this->normalizeShopQueryNumericFocusSearchText($value); $value = preg_replace('/[^0-9,]+/u', '', $value) ?? $value; return trim($value, ','); } private function normalizeShopQueryNumericFocusUnit(string $unit): string { $unit = $this->normalizeShopQueryNumericFocusSearchText($unit); $unit = preg_replace('/[^\p{L}]+/u', '', $unit) ?? $unit; return $unit; } private function normalizeShopQueryNumericFocusSearchText(string $value): string { $value = mb_strtolower(trim($value), 'UTF-8'); $value = $this->languageCleanupConfig->normalizeDashEquivalents($value); $value = str_replace('.', ',', $value); $value = preg_replace('/\s+/u', '', $value) ?? $value; $value = preg_replace('/[^\p{L}\p{N},]+/u', '', $value) ?? $value; return trim($value); } private function standaloneOptimizedShopQueryIntroducesUnsupportedContext( string $prompt, string $optimizedShopQuery ): bool { $promptTokens = array_fill_keys($this->tokenizeShopQueryCandidate($prompt), true); $optimizedTokens = $this->tokenizeShopQueryCandidate($optimizedShopQuery); if ($optimizedTokens === [] || $promptTokens === []) { return false; } $overlap = 0; foreach ($optimizedTokens as $token) { if (isset($promptTokens[$token])) { $overlap++; continue; } // A standalone query optimizer may remove words, but it must not add // model numbers or article-like numbers that are absent from the // current user input. Otherwise old context can leak into new shop // searches, for example "Anschlusskabel pH/Redox" -> "testomat 808". if (preg_match('/\d/u', $token) === 1) { return true; } } // If the optimized query has no token overlap with the current standalone // input, it is not a safe optimization but a context substitution. return $overlap === 0 && !$this->isMetaOnlyShopQuery($prompt); } private function resolveShopSearchQuery( string $prompt, string $optimizedShopQuery, string $commerceHistoryContext, string $userId, string $currentPromptFallback = '' ): string { if ($this->isCommercialTableFollowUpPrompt($prompt)) { foreach ($this->buildCommercialTableFollowUpContextCandidates($commerceHistoryContext, $userId) as $contextCandidate) { $commercialTableContextQuery = $this->extractCommercialTableFollowUpShopQuery($contextCandidate); if ($commercialTableContextQuery !== '' && !$this->isMetaOnlyShopQuery($commercialTableContextQuery)) { return $commercialTableContextQuery; } } } if ($optimizedShopQuery !== '' && !$this->isMetaOnlyShopQuery($optimizedShopQuery)) { return $this->guardStandaloneOptimizedShopQuery($prompt, $optimizedShopQuery); } $currentPromptFallback = trim($currentPromptFallback); if ($currentPromptFallback !== '' && !$this->isMetaOnlyShopQuery($currentPromptFallback)) { return $currentPromptFallback; } if (!$this->isMetaOnlyShopQuery($prompt)) { return $prompt; } $contextQuery = $this->extractContextualShopSearchQuery($commerceHistoryContext); if ($contextQuery !== '' && !$this->isMetaOnlyShopQuery($contextQuery)) { return $contextQuery; } $extendedHistoryBudget = $this->agentRunnerConfig->getShopQueryContextFallbackHistoryBudgetChars(); if ($extendedHistoryBudget > mb_strlen($commerceHistoryContext, 'UTF-8')) { $extendedHistory = $this->contextService->buildUserContextWithinBudget($userId, $extendedHistoryBudget); $extendedContextQuery = $this->extractContextualShopSearchQuery($extendedHistory); if ($extendedContextQuery !== '' && !$this->isMetaOnlyShopQuery($extendedContextQuery)) { return $extendedContextQuery; } } if ($this->agentRunnerConfig->shouldUseFullHistoryForShopQueryContextFallback()) { $fullHistory = $this->contextService->buildUserContext($userId, true); $fullHistoryContextQuery = $this->extractContextualShopSearchQuery($fullHistory); if ($fullHistoryContextQuery !== '' && !$this->isMetaOnlyShopQuery($fullHistoryContextQuery)) { return $fullHistoryContextQuery; } } return ''; } /** * @return string[] */ private function buildCommercialTableFollowUpContextCandidates(string $commerceHistoryContext, string $userId): array { $candidates = []; $commerceHistoryContext = trim($commerceHistoryContext); if ($commerceHistoryContext !== '') { $candidates[] = $commerceHistoryContext; } $extendedHistoryBudget = $this->agentRunnerConfig->getShopQueryContextFallbackHistoryBudgetChars(); if ($extendedHistoryBudget > mb_strlen($commerceHistoryContext, 'UTF-8')) { $extendedHistory = trim($this->contextService->buildUserContextWithinBudget($userId, $extendedHistoryBudget)); if ($extendedHistory !== '') { $candidates[] = $extendedHistory; } } if ($this->agentRunnerConfig->shouldUseFullHistoryForShopQueryContextFallback()) { $fullHistory = trim($this->contextService->buildUserContext($userId, true)); if ($fullHistory !== '') { $candidates[] = $fullHistory; } } return array_values(array_unique($candidates)); } private function extractCommercialTableFollowUpShopQuery(string $commerceHistoryContext): string { if (!$this->agentRunnerConfig->isCommercialTableFollowUpEnabled()) { return ''; } $fallbackWithoutModel = ''; foreach ($this->extractHistoryTurnsNewestFirst($commerceHistoryContext) as $turn) { if (!$this->matchesAnyConfiguredPattern( $turn, $this->agentRunnerConfig->getCommercialTableFollowUpIndicatorMarkerPatterns() )) { continue; } $model = $this->referenceAnchorExtractor->extractFirstProductModelAnchor($turn); if ($model !== '') { $query = str_replace( '{model}', $model, $this->agentRunnerConfig->getCommercialTableFollowUpQueryTemplateWithModel() ); return trim((string) preg_replace('/\s+/u', ' ', $query)); } $fallbackWithoutModel = trim($this->agentRunnerConfig->getCommercialTableFollowUpQueryTemplateWithoutModel()); } return $fallbackWithoutModel; } private function isCommercialTableFollowUpPrompt(string $prompt): bool { if (!$this->agentRunnerConfig->isCommercialTableFollowUpEnabled()) { return false; } return $this->matchesAnyConfiguredPattern( $this->normalizeFollowUpText($prompt), $this->agentRunnerConfig->getCommercialTableFollowUpPromptPatterns() ); } /** * @param string[] $patterns */ private function matchesAnyConfiguredPattern(string $text, array $patterns): bool { foreach ($patterns as $pattern) { if (preg_match($pattern, $text) === 1) { return true; } } return false; } private function extractContextualShopSearchQuery(string $commerceHistoryContext): string { if (!$this->agentRunnerConfig->isShopQueryContextFallbackEnabled()) { return ''; } $questions = $this->extractRecentUserQuestions( $commerceHistoryContext, $this->agentRunnerConfig->getShopQueryContextFallbackQuestionLimit() ); for ($i = count($questions) - 1; $i >= 0; $i--) { $question = trim($questions[$i]); if ($question === '' || $this->isMetaOnlyShopQuery($question)) { continue; } $contextQuery = $this->buildContextFallbackShopQuery($question); if ($this->isUsableContextualShopQuery($contextQuery)) { return $contextQuery; } } return $this->extractContextualShopSearchQueryFromHistoryTurns($commerceHistoryContext); } private function extractContextualShopSearchQueryFromHistoryTurns(string $commerceHistoryContext): string { foreach ($this->extractHistoryTurnsNewestFirst($commerceHistoryContext) as $turn) { $question = $this->extractQuestionFromHistoryTurn($turn); if ($question !== '' && !$this->isMetaOnlyShopQuery($question)) { $contextQuery = $this->buildContextFallbackShopQuery($question); if ($this->isUsableContextualShopQuery($contextQuery)) { return $contextQuery; } } $modelAnchor = $this->referenceAnchorExtractor->extractFirstProductModelAnchor($turn); if ($modelAnchor !== '' && !$this->isMetaOnlyShopQuery($modelAnchor)) { return mb_strtolower($modelAnchor, 'UTF-8'); } } return ''; } private function extractQuestionFromHistoryTurn(string $turn): string { if (preg_match($this->agentRunnerConfig->getFollowUpHistoryQuestionPattern(), $turn, $matches) !== 1) { return ''; } return $this->sanitizeHistoryQuestion((string) ($matches[1] ?? '')); } private function isUsableContextualShopQuery(string $query): bool { $query = trim($query); if ($query === '' || $this->isMetaOnlyShopQuery($query)) { return false; } return $this->tokenizeShopQueryCandidate($query) !== []; } private function buildContextFallbackShopQuery(string $question): string { $tokens = $this->tokenizeShopQueryCandidate($question); if ($tokens === []) { return ''; } $filterTerms = []; foreach ($this->getShopQueryContextFallbackFilterTerms() as $term) { foreach ($this->tokenizeShopQueryCandidate($term) as $token) { $filterTerms[$token] = true; } } $maxTerms = max(1, $this->agentRunnerConfig->getShopQueryContextFallbackMaxTerms()); $out = []; foreach ($tokens as $token) { if (isset($filterTerms[$token])) { continue; } if (in_array($token, $out, true)) { continue; } $out[] = $token; if (count($out) >= $maxTerms) { break; } } return implode(' ', $out); } /** @return string[] */ private function getShopQueryContextFallbackFilterTerms(): array { $profileName = $this->agentRunnerConfig->getShopQueryContextFallbackCleanupProfile(); return $this->mergeUniqueStrings( $this->mergeUniqueStrings( $this->languageCleanupConfig->getStopWordsForProfile($profileName), $this->languageCleanupConfig->getPhrasesForProfile($profileName) ), $this->mergeUniqueStrings( $this->languageCleanupConfig->getMetaTermsForProfile($profileName), $this->mergeUniqueStrings( $this->agentRunnerConfig->getShopQueryMetaOnlyTerms(), $this->agentRunnerConfig->getShopQueryContextFallbackFilterTerms() ) ) ); } /** @return string[] */ private function getShopQueryMetaGuardTerms(): array { $profileName = $this->agentRunnerConfig->getShopQueryContextFallbackCleanupProfile(); return $this->mergeUniqueStrings( $this->mergeUniqueStrings( $this->languageCleanupConfig->getStopWordsForProfile($profileName), $this->languageCleanupConfig->getPhrasesForProfile($profileName) ), $this->mergeUniqueStrings( $this->languageCleanupConfig->getMetaTermsForProfile($profileName), $this->agentRunnerConfig->getShopQueryMetaOnlyTerms() ) ); } /** * @return string[] */ private function tokenizeShopQueryCandidate(string $value): array { $value = mb_strtolower(trim($value), 'UTF-8'); $value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value); if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', $value, $matches) === false) { return []; } return array_values(array_filter( array_map(static fn(string $token): string => trim($token), $matches[0] ?? []), static fn(string $token): bool => $token !== '' )); } private function isMetaOnlyShopQuery(string $query): bool { if (!$this->agentRunnerConfig->isShopQueryMetaGuardEnabled()) { return false; } $tokens = $this->tokenizeMetaGuardText($query); if ($tokens === []) { return true; } $metaTerms = []; foreach ($this->getShopQueryMetaGuardTerms() as $term) { foreach ($this->tokenizeMetaGuardText($term) as $token) { $metaTerms[$token] = true; } } if ($metaTerms === []) { return false; } foreach ($tokens as $token) { if (!isset($metaTerms[$token])) { return false; } } return true; } /** * @return string[] */ private function tokenizeMetaGuardText(string $value): array { $value = mb_strtolower(trim($value), 'UTF-8'); $value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value); $value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value; $value = preg_replace('/\s+/u', ' ', $value) ?? $value; $value = trim($value); if ($value === '') { return []; } return array_values(array_filter( explode(' ', $value), static fn(string $token): bool => $token !== '' )); } private function searchShop( string $query, string $commerceIntent, string $userId, string $commerceHistoryContext = '' ): array { try { return $this->shopSearchService->search( $query, $commerceIntent, $commerceHistoryContext ); } catch (Throwable $e) { $this->agentLogger->warning('Shop search failed, continuing without shop results', [ 'userId' => $userId, 'commerceIntent' => $commerceIntent, 'query' => $query, 'hasCommerceHistoryContext' => $commerceHistoryContext !== '', 'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext), 'exception' => $e, ]); return []; } } private function buildCommerceHistoryContext(string $userId, string $requestContextHint = ''): string { $history = $this->contextService->buildUserContextWithinBudget( $userId, $this->agentRunnerConfig->getCommerceHistoryBudgetChars() ); $requestContextHint = $this->sanitizeRequestContextHintForCommerce($requestContextHint); if ($requestContextHint === '') { return $history; } if ($history === '') { return $requestContextHint; } return trim($history) . "\n\n" . $requestContextHint; } private function sanitizeRequestContextHintForCommerce(string $requestContextHint): string { $requestContextHint = str_replace(["\r\n", "\r"], "\n", $requestContextHint); $requestContextHint = preg_replace('/[\t ]+/u', ' ', $requestContextHint) ?? $requestContextHint; $requestContextHint = preg_replace('/\n{3,}/u', "\n\n", $requestContextHint) ?? $requestContextHint; $requestContextHint = trim($requestContextHint); if ($requestContextHint === '') { return ''; } if (mb_strlen($requestContextHint, 'UTF-8') > 4000) { $requestContextHint = mb_substr($requestContextHint, 0, 4000, 'UTF-8'); } return trim($requestContextHint); } private function limitKnowledgeChunks(array $knowledgeChunks, string $commerceIntent): array { return match ($commerceIntent) { CommerceIntentLite::PRODUCT_SEARCH => array_slice( $knowledgeChunks, 0, $this->agentRunnerConfig->getProductSearchKnowledgeChunkLimit() ), CommerceIntentLite::ADVISORY_PRODUCT_SEARCH => array_slice( $knowledgeChunks, 0, $this->agentRunnerConfig->getAdvisoryProductSearchKnowledgeChunkLimit() ), default => $knowledgeChunks, }; } private function sanitizeOptimizedShopQuery( string $query, string $sourcePrompt = '', string $commerceHistoryContext = '' ): string { $query = trim($query); if ($query === '') { return ''; } $query = preg_split('/\R+/u', $query, 2)[0] ?? $query; $query = preg_replace($this->agentRunnerConfig->getOptimizedShopQueryPrefixPattern(), '', $query) ?? $query; $query = trim($query, $this->agentRunnerConfig->getOptimizedShopQueryTrimCharacters()); $query = preg_replace('/\s+/u', ' ', $query) ?? $query; $query = $this->preserveOptimizedShopQueryLanguage($query, $sourcePrompt); $query = $this->enrichReferentialShopQueryFromHistory($query, $sourcePrompt, $commerceHistoryContext); $query = preg_replace('/\s+/u', ' ', $query) ?? $query; return trim($query); } private function guardReferentialShopQueryFallbackWithHistoryAnchor( string $prompt, string $shopSearchQuery, string $commerceHistoryContext ): string { if (!$this->agentRunnerConfig->isShopQueryContextAnchorEnrichmentEnabled()) { return $shopSearchQuery; } if (trim($commerceHistoryContext) === '') { return $shopSearchQuery; } if (!$this->shouldUseCommerceHistoryForShopQuery($prompt)) { return $shopSearchQuery; } $combined = trim($shopSearchQuery . ' ' . $prompt); if (!$this->containsConfiguredShopQueryAnchorTrigger($combined)) { return $shopSearchQuery; } $anchor = $this->normalizeShopQueryAnchor( $this->extractLatestConfiguredShopQueryContextAnchor($commerceHistoryContext) ); if ($anchor === '' || $this->queryAlreadyContainsAllAnchorTokens($shopSearchQuery, $anchor)) { return $shopSearchQuery; } $referentialQuery = $this->extractReferentialShopQueryTriggerTerms($combined); if ($referentialQuery === '') { return $shopSearchQuery; } $template = $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTemplate(); $enriched = $this->renderAgentTemplate($template, [ 'anchor' => $anchor, 'query' => $referentialQuery, ]); $enriched = preg_replace('/\s+/u', ' ', $enriched) ?? $enriched; $enriched = trim($enriched); return $enriched !== '' ? $enriched : $shopSearchQuery; } private function guardMainDeviceReferentialShopQueryWithHistoryModelAnchor( string $prompt, string $shopSearchQuery, string $commerceHistoryContext ): string { $shopSearchQuery = trim($shopSearchQuery); if ( $shopSearchQuery === '' || trim($commerceHistoryContext) === '' || $this->referenceAnchorExtractor->extractFirstProductModelAnchor($prompt) !== '' || $this->referenceAnchorExtractor->extractFirstProductModelAnchor($shopSearchQuery) !== '' ) { return $shopSearchQuery; } if (!$this->isMainDeviceReferentialShopQueryPrompt($prompt)) { return $shopSearchQuery; } if (!$this->isGenericMainDeviceReferentialShopQuery($shopSearchQuery)) { return $shopSearchQuery; } $modelAnchor = $this->normalizeShopQueryAnchor( $this->extractLatestHistoryProductModelAnchor($commerceHistoryContext) ); if ($modelAnchor === '') { return $shopSearchQuery; } return $this->queryAlreadyContainsAllAnchorTokens($shopSearchQuery, $modelAnchor) ? $shopSearchQuery : $modelAnchor; } private function isMainDeviceReferentialShopQueryPrompt(string $prompt): bool { $tokens = $this->tokenizeShopQueryCandidate($prompt); if ($tokens === []) { return false; } $tokenSet = array_fill_keys($tokens, true); $mainDeviceTokens = $this->buildShopQueryTokenSet( $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords() ); if (!$this->tokenSetIntersects($tokenSet, $mainDeviceTokens)) { return false; } $accessoryTokens = $this->buildShopQueryTokenSet($this->mergeUniqueStrings( $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords(), $this->agentRunnerConfig->getRequestedAccessoryCodeTerms() )); if ($this->tokenSetIntersects($tokenSet, $accessoryTokens)) { return false; } $referenceTokens = $this->buildShopQueryTokenSet($this->mergeUniqueStrings( $this->agentRunnerConfig->getShopQueryContextUsageReferentialTerms(), $this->agentRunnerConfig->getShopQueryMetaOnlyTerms() )); return $this->tokenSetIntersects($tokenSet, $referenceTokens); } private function isGenericMainDeviceReferentialShopQuery(string $shopSearchQuery): bool { $tokens = $this->tokenizeShopQueryCandidate($shopSearchQuery); if ($tokens === []) { return false; } foreach ($tokens as $token) { if (preg_match('/\d/u', $token) === 1) { return false; } } $genericTerms = $this->mergeUniqueStrings( $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords(), $this->agentRunnerConfig->getShopQueryContextUsageReferentialTerms() ); $genericTerms = $this->mergeUniqueStrings($genericTerms, $this->agentRunnerConfig->getShopQueryMetaOnlyTerms()); $genericTerms = $this->mergeUniqueStrings($genericTerms, $this->agentRunnerConfig->getShopQueryContextFallbackFilterTerms()); $genericTerms = $this->mergeUniqueStrings($genericTerms, $this->agentRunnerConfig->getShopQueryStopwordCleanupTerms()); $genericTokens = $this->buildShopQueryTokenSet($genericTerms); if ($genericTokens === []) { return false; } $hasMainDeviceToken = false; $mainDeviceTokens = $this->buildShopQueryTokenSet( $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords() ); foreach ($tokens as $token) { if (!isset($genericTokens[$token])) { return false; } if (isset($mainDeviceTokens[$token])) { $hasMainDeviceToken = true; } } return $hasMainDeviceToken; } private function extractLatestHistoryProductModelAnchor(string $commerceHistoryContext): string { foreach ($this->extractHistoryTurnsNewestFirst($commerceHistoryContext) as $turn) { $modelAnchor = $this->referenceAnchorExtractor->extractFirstProductModelAnchor($turn); if ($modelAnchor !== '') { return $modelAnchor; } } return ''; } /** * @param array $left * @param array $right */ private function tokenSetIntersects(array $left, array $right): bool { foreach ($left as $token => $_) { if (isset($right[$token])) { return true; } } return false; } private function extractReferentialShopQueryTriggerTerms(string $text): string { $tokens = $this->tokenizeShopQueryCandidate($text); if ($tokens === []) { return ''; } $triggerTokens = $this->buildShopQueryTokenSet( $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTriggerTerms() ); if ($triggerTokens === []) { return ''; } $hasTrigger = false; foreach ($tokens as $token) { if (isset($triggerTokens[$token])) { $hasTrigger = true; break; } } if (!$hasTrigger) { return ''; } $queryTokens = $this->buildShopQueryTokenSet( $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentQueryTerms() ); if ($queryTokens === []) { $queryTokens = $triggerTokens; } $noiseTokens = $this->buildShopQueryTokenSet( $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentQueryNoiseTerms() ); $out = []; foreach ($tokens as $token) { if (!isset($queryTokens[$token]) || isset($noiseTokens[$token]) || isset($out[$token])) { continue; } $out[$token] = $token; } return implode(' ', array_values($out)); } /** * @param string[] $terms * @return array */ private function buildShopQueryTokenSet(array $terms): array { $tokens = []; foreach ($terms as $term) { foreach ($this->tokenizeShopQueryCandidate($term) as $termToken) { $tokens[$termToken] = true; } } return $tokens; } private function enrichReferentialShopQueryFromHistory( string $query, string $sourcePrompt, string $commerceHistoryContext ): string { if (!$this->agentRunnerConfig->isShopQueryContextAnchorEnrichmentEnabled()) { return $query; } if (trim($commerceHistoryContext) === '') { return $query; } $queryTokens = $this->tokenizeShopQueryCandidate($query); if ($queryTokens === []) { return $query; } $maxTerms = max(1, $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentMaxQueryTerms()); if (count($queryTokens) > $maxTerms) { return $query; } if (!$this->containsConfiguredShopQueryAnchorTrigger(trim($query . ' ' . $sourcePrompt))) { return $query; } $anchor = $this->normalizeShopQueryAnchor( $this->extractLatestConfiguredShopQueryContextAnchor($commerceHistoryContext) ); if ($anchor === '' || $this->queryAlreadyContainsAllAnchorTokens($query, $anchor)) { return $query; } $template = $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTemplate(); $enriched = $this->renderAgentTemplate($template, [ 'anchor' => $anchor, 'query' => $query, ]); $enriched = preg_replace('/\s+/u', ' ', $enriched) ?? $enriched; return trim($enriched) !== '' ? trim($enriched) : $query; } private function containsConfiguredShopQueryAnchorTrigger(string $text): bool { $tokens = $this->tokenizeShopQueryCandidate($text); if ($tokens === []) { return false; } $tokenSet = array_fill_keys($tokens, true); foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTriggerTerms() as $term) { foreach ($this->tokenizeShopQueryCandidate($term) as $termToken) { if (isset($tokenSet[$termToken])) { return true; } } } return false; } private function extractLatestConfiguredShopQueryContextAnchor(string $commerceHistoryContext): string { foreach ($this->extractHistoryTurnsNewestFirst($commerceHistoryContext) as $turn) { if (!$this->containsConfiguredShopQueryAnchorTrigger($turn)) { continue; } $modelAnchor = $this->referenceAnchorExtractor->extractFirstProductModelAnchor($turn); $turnAnchor = $this->extractLatestConfiguredShopQueryPatternAnchor($turn); if ($modelAnchor !== '') { return $this->buildModelQualifiedShopQueryAnchor($modelAnchor, $turnAnchor); } if ($turnAnchor !== '') { return $turnAnchor; } } return $this->extractLatestConfiguredShopQueryPatternAnchor($commerceHistoryContext); } private function extractLatestConfiguredShopQueryPatternAnchor(string $text): string { $latest = ''; foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentPatterns() as $pattern) { if (@preg_match_all($pattern, $text, $matches, PREG_SET_ORDER) === false) { continue; } foreach ($matches as $match) { $candidate = trim((string) ($match[0] ?? '')); if ($candidate !== '') { $latest = $candidate; } } } return $latest; } private function buildModelQualifiedShopQueryAnchor(string $modelAnchor, string $detailAnchor): string { $modelAnchor = trim($modelAnchor); if ($modelAnchor === '') { return trim($detailAnchor); } $detailTokens = $this->extractShopQueryDetailAnchorTokens($detailAnchor, $modelAnchor); if ($detailTokens === []) { return $modelAnchor; } return trim($modelAnchor . ' ' . implode(' ', $detailTokens)); } /** * @return string[] */ private function extractShopQueryDetailAnchorTokens(string $detailAnchor, string $modelAnchor): array { $tokens = $this->tokenizeShopQueryCandidate($detailAnchor); if ($tokens === []) { return []; } $modelTokens = array_fill_keys($this->tokenizeShopQueryCandidate($modelAnchor), true); $queryTokens = $this->buildShopQueryTokenSet( $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentQueryTerms() ); $noiseTokens = $this->buildShopQueryTokenSet( $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentQueryNoiseTerms() ); $out = []; foreach ($tokens as $token) { if (isset($modelTokens[$token]) || isset($queryTokens[$token]) || isset($noiseTokens[$token]) || isset($out[$token])) { continue; } $out[$token] = $token; } return array_values($out); } private function normalizeShopQueryAnchor(string $anchor): string { $anchor = str_replace('®', '', $anchor); $anchor = mb_strtolower(trim($anchor), 'UTF-8'); $anchor = preg_replace('/[^\p{L}\p{N},.%°+\-\s]+/u', ' ', $anchor) ?? $anchor; $anchor = preg_replace('/\s+/u', ' ', $anchor) ?? $anchor; return trim($anchor); } private function queryAlreadyContainsAllAnchorTokens(string $query, string $anchor): bool { $queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($query), true); foreach ($this->tokenizeShopQueryCandidate($anchor) as $token) { if (!isset($queryTokens[$token])) { return false; } } return true; } private function preserveOptimizedShopQueryLanguage(string $query, string $sourcePrompt): string { if (!$this->agentRunnerConfig->isShopQueryLanguagePreservationEnabled()) { return $query; } $language = $this->detectConfiguredShopQueryLanguage($sourcePrompt); if ($language === null) { return $query; } $replacements = $this->agentRunnerConfig->getShopQueryTranslationReplacements($language); if ($replacements === []) { return $query; } foreach ($replacements as $source => $target) { $pattern = '/(?agentRunnerConfig->getShopQueryLanguageMarkers() as $language => $markers) { foreach ($markers as $marker) { if ($marker !== '' && str_contains($normalized, $marker)) { return $language; } } } return null; } /** * @return Generator */ private function streamFinalAnswer(string $finalPrompt, string $noLlmFallbackAnswer = ''): Generator { $fullOutput = ''; $thinkingNoticeShown = false; $stoppedByFinalAnswerGuard = false; $chunker = new StreamChunker(); $this->thinkSuppressor->reset(); yield $this->systemMsg($this->agentRunnerConfig->getThinkingWhileStreamingMessage(), 'think'); $thinkingNoticeShown = true; try { foreach ($this->ollamaClient->stream($finalPrompt) as $token) { if (!is_string($token)) { continue; } $cleanToken = $this->thinkSuppressor->filter($token); if ($cleanToken === '') { if (!$thinkingNoticeShown) { yield $this->systemMsg($this->agentRunnerConfig->getThinkingWhileStreamingMessage(), 'think'); $thinkingNoticeShown = true; } continue; } $guardReason = null; $cleanToken = $this->guardFinalAnswerToken($fullOutput, $cleanToken, $guardReason); if ($cleanToken !== '') { $fullOutput .= $cleanToken; $chunk = $chunker->push($cleanToken); if ($chunk !== null) { yield $this->systemMsg($chunk, 'answer'); } } if ($guardReason !== null) { $stoppedByFinalAnswerGuard = true; $finalChunk = $chunker->flush(); if ($finalChunk !== null) { yield $this->systemMsg($finalChunk, 'answer'); } $guardMessage = $this->agentRunnerConfig->getFinalAnswerGuardTruncationMessage(); $fullOutput .= $guardMessage; yield $this->systemMsg($guardMessage, 'answer'); $this->agentLogger->warning('Final answer guard stopped LLM output', [ 'reason' => $guardReason, 'outputLength' => mb_strlen($fullOutput, 'UTF-8'), ]); break; } } } catch (Throwable $e) { $noLlmFallbackAnswer = trim($noLlmFallbackAnswer); if ($noLlmFallbackAnswer === '' || $fullOutput !== '') { throw $e; } $this->agentLogger->warning('LLM stream failed before answer tokens, using deterministic no-LLM fallback answer', [ 'exception' => $e, ]); $fullOutput = $noLlmFallbackAnswer; yield $this->systemMsg($noLlmFallbackAnswer, 'answer'); return $fullOutput; } if ($stoppedByFinalAnswerGuard) { return $fullOutput; } $finalChunk = $chunker->flush(); if ($finalChunk !== null) { yield $this->systemMsg($finalChunk, 'answer'); } elseif ($fullOutput === '') { $noLlmFallbackAnswer = trim($noLlmFallbackAnswer); if ($noLlmFallbackAnswer !== '') { $fullOutput = $noLlmFallbackAnswer; yield $this->systemMsg($noLlmFallbackAnswer, 'answer'); } else { yield $this->systemMsg($this->agentRunnerConfig->getNoLlmDataReceivedMessage(), 'err'); } } return $fullOutput; } private function guardFinalAnswerToken(string $currentOutput, string $nextToken, ?string &$reason): string { $reason = null; if (!$this->agentRunnerConfig->isFinalAnswerGuardEnabled()) { return $nextToken; } $maxOutputChars = max(1000, $this->agentRunnerConfig->getFinalAnswerGuardMaxOutputChars()); $currentChars = mb_strlen($currentOutput, 'UTF-8'); $nextChars = mb_strlen($nextToken, 'UTF-8'); if (($currentChars + $nextChars) > $maxOutputChars) { $reason = 'max_output_chars'; $remainingChars = max(0, $maxOutputChars - $currentChars); return $remainingChars > 0 ? mb_substr($nextToken, 0, $remainingChars, 'UTF-8') : ''; } $candidate = $currentOutput . $nextToken; $cutoffBytes = $this->detectRepeatedFinalAnswerLineCutoff($candidate); if ($cutoffBytes === null) { return $nextToken; } $reason = 'repeated_line'; $currentBytes = strlen($currentOutput); if ($cutoffBytes <= $currentBytes) { return ''; } return mb_strcut($nextToken, 0, $cutoffBytes - $currentBytes, 'UTF-8'); } private function detectRepeatedFinalAnswerLineCutoff(string $text): ?int { if (!$this->agentRunnerConfig->isFinalAnswerRepeatedLineGuardEnabled()) { return null; } if (mb_strlen($text, 'UTF-8') < max(0, $this->agentRunnerConfig->getFinalAnswerRepeatedLineMinOutputChars())) { return null; } if (preg_match_all('/[^\r\n]+/u', $text, $matches, PREG_OFFSET_CAPTURE) === false) { return null; } $lines = $matches[0] ?? []; $window = max(10, $this->agentRunnerConfig->getFinalAnswerRepeatedLineTrailingWindowLines()); if (count($lines) > $window) { $lines = array_slice($lines, -$window); } $counts = []; $maxRepetitions = max(1, $this->agentRunnerConfig->getFinalAnswerRepeatedLineMaxRepetitions()); foreach ($lines as $lineMatch) { $line = (string) ($lineMatch[0] ?? ''); $offset = (int) ($lineMatch[1] ?? 0); $normalizedLine = $this->normalizeFinalAnswerLineForRepetitionGuard($line); if ($normalizedLine === '') { continue; } $counts[$normalizedLine] = ($counts[$normalizedLine] ?? 0) + 1; if ($counts[$normalizedLine] > $maxRepetitions) { return $offset; } } return null; } private function normalizeFinalAnswerLineForRepetitionGuard(string $line): string { $line = html_entity_decode(strip_tags($line), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $line = preg_replace('/^\s*(?:[-*•]+|\d+[.)])\s*/u', '', $line) ?? $line; $line = preg_replace('/\s+/u', ' ', $line) ?? $line; $line = trim($line, " \t\n\r\0\x0B:;.-"); if ($line === '') { return ''; } foreach ($this->agentRunnerConfig->getFinalAnswerRepeatedLineIgnorePatterns() as $pattern) { try { if (@preg_match($pattern, $line) === 1) { return ''; } } catch (Throwable) { continue; } } if (mb_strlen($line, 'UTF-8') < max(1, $this->agentRunnerConfig->getFinalAnswerRepeatedLineMinLineChars())) { return ''; } return mb_strtolower($line, 'UTF-8'); } /** * @param ShopProductResult[] $shopResults * @return ShopProductResult[] */ private function guardDirectProductShopResults(string $prompt, string $shopSearchQuery, array $shopResults): array { if ( $shopResults === [] || !$this->agentRunnerConfig->isDirectShopResultGuardEnabled() ) { return $shopResults; } $requestedTerms = $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery); if ($requestedTerms === []) { return $shopResults; } $primaryMatches = []; $corpusMatches = []; foreach ($shopResults as $product) { if (!$product instanceof ShopProductResult) { continue; } if ($this->shopProductPrimaryIdentityMatchesAnyDirectProductTerm($product, $requestedTerms)) { $primaryMatches[] = $product; continue; } if ($this->shopProductMatchesAnyDirectProductTerm($product, $requestedTerms)) { $corpusMatches[] = $product; } } if ($this->agentRunnerConfig->shouldPreferDirectShopResultGuardPrimaryIdentityMatches()) { return $primaryMatches; } return array_values(array_merge($primaryMatches, $corpusMatches)); } /** * @param ShopProductResult[] $unguardedShopResults * @param ShopProductResult[] $guardedShopResults * @return array{results: array|null, attemptedRepair: bool, usedRepair: bool, repairQueries: string[]} */ private function repairEmptyDirectProductPrimaryIdentityResults( string $prompt, string $userId, string $commerceIntent, string $shopSearchQuery, array $unguardedShopResults, array $guardedShopResults ): array { $emptyResult = [ 'results' => null, 'attemptedRepair' => false, 'usedRepair' => false, 'repairQueries' => [], ]; if ( $guardedShopResults !== [] || $unguardedShopResults === [] || !$this->agentRunnerConfig->isDirectShopResultGuardPrimaryIdentityRepairEnabled() ) { return $emptyResult; } $requestedTerms = $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery); if ($requestedTerms === []) { return $emptyResult; } $repairQuery = $this->buildDirectProductPrimaryIdentityRepairQuery( shopSearchQuery: $shopSearchQuery, requestedTerms: $requestedTerms ); if ($repairQuery === '' || $this->normalizeShopQueryForComparison($repairQuery) === $this->normalizeShopQueryForComparison($shopSearchQuery)) { return $emptyResult; } $this->agentLogger->info('Direct product primary identity guard retrying with cleaned repair query', [ 'userId' => $userId, 'commerceIntent' => $commerceIntent, 'prompt' => $prompt, 'shopSearchQuery' => $shopSearchQuery, 'repairQuery' => $repairQuery, 'unguardedShopResultsCount' => count($unguardedShopResults), 'requestedTerms' => $requestedTerms, ]); $repairResults = $this->searchShop( $repairQuery, $commerceIntent, $userId, '' ); if ($this->shopSearchService->hadLastSearchSystemFailure()) { return [ 'results' => null, 'attemptedRepair' => true, 'usedRepair' => false, 'repairQueries' => [$repairQuery], ]; } $guardedRepairResults = $this->guardDirectProductShopResults($prompt, $repairQuery, $repairResults); if ($guardedRepairResults === []) { $this->agentLogger->info('Direct product primary identity repair finished without matching products', [ 'userId' => $userId, 'commerceIntent' => $commerceIntent, 'prompt' => $prompt, 'shopSearchQuery' => $shopSearchQuery, 'repairQuery' => $repairQuery, 'repairResultsCount' => count($repairResults), ]); return [ 'results' => null, 'attemptedRepair' => true, 'usedRepair' => false, 'repairQueries' => [$repairQuery], ]; } return [ 'results' => $guardedRepairResults, 'attemptedRepair' => true, 'usedRepair' => true, 'repairQueries' => [$repairQuery], ]; } /** * @param string[] $requestedTerms */ private function buildDirectProductPrimaryIdentityRepairQuery(string $shopSearchQuery, array $requestedTerms): string { $tokens = $this->tokenizeShopQueryCandidate($shopSearchQuery); if ($tokens === []) { return ''; } $stopTokens = []; foreach ($this->agentRunnerConfig->getDirectShopResultGuardPrimaryIdentityRepairStopTerms() as $term) { foreach ($this->tokenizeShopQueryCandidate($term) as $token) { $stopTokens[$token] = true; } } $requestedTokens = []; foreach ($requestedTerms as $term) { foreach ($this->tokenizeShopQueryCandidate($term) as $token) { $requestedTokens[$token] = true; } } $kept = []; foreach ($tokens as $token) { if (isset($stopTokens[$token]) && !isset($requestedTokens[$token])) { continue; } if (isset($kept[$token])) { continue; } $kept[$token] = $token; } foreach (array_keys($requestedTokens) as $requestedToken) { if (!isset($kept[$requestedToken])) { $kept[$requestedToken] = $requestedToken; } } if (count($kept) < max(1, $this->agentRunnerConfig->getDirectShopResultGuardPrimaryIdentityRepairMinQueryTokens())) { return ''; } return trim(implode(' ', array_values($kept))); } private function normalizeShopQueryForComparison(string $query): string { return trim(implode(' ', $this->tokenizeShopQueryCandidate($query))); } /** * @param ShopProductResult[] $shopResults * @return ShopProductResult[] */ private function guardShopResultsByReferencedProductAnchor(string $shopSearchQuery, array $shopResults): array { if ($shopResults === []) { return $shopResults; } $anchor = $this->referenceAnchorExtractor->extractFirstProductModelAnchor($shopSearchQuery); if ($anchor === '') { return $shopResults; } $filtered = []; foreach ($shopResults as $product) { if (!$product instanceof ShopProductResult) { continue; } if ($this->shopProductMatchesReferencedProductAnchor($product, $anchor)) { $filtered[] = $product; } } return $filtered; } private function shopProductMatchesReferencedProductAnchor(ShopProductResult $product, string $anchor): bool { $productText = trim(implode(' ', array_filter([ $product->name, $product->description, implode(' ', $product->highlights), $product->customFields, $product->url, ]))); return $this->containsAllShopQueryTokens($productText, $anchor); } /** * @param ShopProductResult[] $shopResults * @return ShopProductResult[] */ private function guardShopResultsByExactRequestedAccessoryCode(string $prompt, string $shopSearchQuery, array $shopResults): array { if ($shopResults === []) { return $shopResults; } $requestedCodes = $this->extractExactRequestedAccessoryCodes($prompt, $shopSearchQuery); if ($requestedCodes === []) { return $shopResults; } $filtered = []; foreach ($shopResults as $product) { if (!$product instanceof ShopProductResult) { continue; } if ($this->shopProductMatchesExactRequestedAccessoryCode($product, $requestedCodes)) { $filtered[] = $product; } } return $filtered !== [] ? $filtered : $shopResults; } /** * @return string[] */ private function extractExactRequestedAccessoryCodes(string $prompt, string $shopSearchQuery): array { $text = $this->normalizeOneLine(trim($prompt . ' ' . $shopSearchQuery)); if ($text === '') { return []; } $codeTerms = $this->agentRunnerConfig->getRequestedAccessoryCodeTerms(); if ($codeTerms === []) { return []; } $tokens = $this->tokenizeAccessoryCodeContext($text); if ($tokens === []) { return []; } $termTokenSequences = []; foreach ($codeTerms as $term) { $termTokens = $this->tokenizeAccessoryCodeContext($term); if ($termTokens !== []) { $termTokenSequences[] = $termTokens; } } if ($termTokenSequences === []) { return []; } $codes = []; foreach ($termTokenSequences as $termTokens) { $termLength = count($termTokens); foreach ($tokens as $position => $_token) { if (!$this->tokenSequenceMatchesAt($tokens, $termTokens, $position)) { continue; } $code = $this->findNearestRequestedAccessoryCodeAfter($tokens, $position + $termLength, 3, $termTokenSequences); if ($code === '') { $code = $this->findNearestRequestedAccessoryCodeBefore($tokens, $position - 1, 3, $termTokenSequences); } if ($code !== '') { $codes[$code] = $code; } } } return array_values($codes); } /** * @param string[] $tokens * @param array $termTokenSequences */ private function findNearestRequestedAccessoryCodeAfter(array $tokens, int $start, int $window, array $termTokenSequences): string { $end = min(count($tokens) - 1, $start + max(0, $window - 1)); for ($index = max(0, $start); $index <= $end; $index++) { $code = $this->buildRequestedAccessoryCodeFromTokenWindow($tokens, $index, $termTokenSequences); if ($code !== '') { return $code; } } return ''; } /** * @param string[] $tokens * @param array $termTokenSequences */ private function findNearestRequestedAccessoryCodeBefore(array $tokens, int $start, int $window, array $termTokenSequences): string { $end = max(0, $start - max(0, $window - 1)); for ($index = min(count($tokens) - 1, $start); $index >= $end; $index--) { $code = $this->buildRequestedAccessoryCodeFromTokenWindow($tokens, $index, $termTokenSequences); if ($code !== '') { return $code; } } return ''; } /** * @param string[] $tokens * @param string[] $needle */ private function tokenSequenceMatchesAt(array $tokens, array $needle, int $position): bool { if ($needle === [] || $position < 0 || $position + count($needle) > count($tokens)) { return false; } foreach ($needle as $offset => $needleToken) { if (($tokens[$position + $offset] ?? null) !== $needleToken) { return false; } } return true; } /** * @param string[] $tokens * @param array $termTokenSequences */ private function buildRequestedAccessoryCodeFromTokenWindow(array $tokens, int $index, array $termTokenSequences): string { $token = $tokens[$index] ?? ''; if (!$this->isStrictAccessoryCodeToken($token)) { return ''; } $next = $tokens[$index + 1] ?? ''; if ($this->isSingleLetterVariantSuffix($next) && !$this->tokenStartsAnyConfiguredTerm($tokens, $termTokenSequences, $index + 1)) { return $this->normalizeAccessoryCodePhrase($token . ' ' . $next); } $previous = $tokens[$index - 1] ?? ''; if ($this->isShortAlphaCodePrefix($previous) && !$this->tokenStartsAnyConfiguredTerm($tokens, $termTokenSequences, $index - 1)) { return $this->normalizeAccessoryCodePhrase($previous . ' ' . $token); } return $this->normalizeAccessoryCodePhrase($token); } /** * @param string[] $tokens * @param array $termTokenSequences */ private function tokenStartsAnyConfiguredTerm(array $tokens, array $termTokenSequences, int $position): bool { foreach ($termTokenSequences as $termTokens) { if ($this->tokenSequenceMatchesAt($tokens, $termTokens, $position)) { return true; } } return false; } /** * @param string[] $requestedCodes */ private function shopProductMatchesExactRequestedAccessoryCode(ShopProductResult $product, array $requestedCodes): bool { $identityText = $this->normalizeOneLine(trim(implode(' ', array_filter([ $product->name, $product->url, ])))); if ($identityText === '') { return false; } $tokens = $this->tokenizeAccessoryCodeContext($identityText); if ($tokens === []) { return false; } foreach ($requestedCodes as $code) { if ($this->accessoryCodeTokensContainExactCode($tokens, $code)) { return true; } } return false; } /** * @param string[] $tokens */ private function accessoryCodeTokensContainExactCode(array $tokens, string $requestedCode): bool { $codeTokens = $this->tokenizeAccessoryCodeContext($requestedCode); if ($codeTokens === []) { return false; } $compactCode = $this->normalizeAccessoryCodeForExactMatch($requestedCode); $codeLength = count($codeTokens); foreach ($tokens as $index => $token) { if ($codeLength === 1 && $this->normalizeAccessoryCodeForExactMatch($token) === $compactCode) { $next = $tokens[$index + 1] ?? ''; if (!$this->isSingleLetterVariantSuffix($next)) { return true; } continue; } if ($this->tokenSequenceMatchesAt($tokens, $codeTokens, $index)) { return true; } if ($this->normalizeAccessoryCodeForExactMatch(implode(' ', array_slice($tokens, $index, $codeLength))) === $compactCode) { return true; } } return false; } /** * @return string[] */ private function tokenizeAccessoryCodeContext(string $text): array { $normalized = mb_strtolower($this->normalizeOneLine($text), 'UTF-8'); if ($normalized === '') { return []; } preg_match_all('/[\p{L}]+\d[\p{L}\p{N}\-]*|\d+(?:[,.]\d+)?[\p{L}\p{N}\-]*|[\p{L}]+/u', $normalized, $matches); return array_values(array_filter( array_map(static fn(string $token): string => trim($token), $matches[0] ?? []), static fn(string $token): bool => $token !== '' )); } private function isStrictAccessoryCodeToken(string $token): bool { $token = trim($token); if ($token === '' || str_contains($token, ',') || str_contains($token, '.')) { return false; } if (preg_match('/^\d+$/u', $token) === 1) { return mb_strlen($token, 'UTF-8') >= 2; } foreach ($this->agentRunnerConfig->getShopQueryPositiveTokenFilterCodePatterns() as $pattern) { if (@preg_match($pattern, $token) === 1) { return true; } } return preg_match('/^(?:[a-z]{1,4}\d{1,5}[a-z0-9-]*|\d{2,5}[a-z0-9-]*)$/iu', $token) === 1; } private function isSingleLetterVariantSuffix(string $token): bool { return preg_match('/^[a-z]$/iu', trim($token)) === 1; } private function isShortAlphaCodePrefix(string $token): bool { return preg_match('/^[a-z]{1,4}$/iu', trim($token)) === 1; } private function normalizeAccessoryCodePhrase(string $code): string { return $this->normalizeOneLine(mb_strtolower($code, 'UTF-8')); } private function normalizeAccessoryCodeForExactMatch(string $code): string { return preg_replace('/[^a-z0-9]+/iu', '', mb_strtolower($code, 'UTF-8')) ?? ''; } /** * @param ShopProductResult[] $shopResults * @return ShopProductResult[] */ private function sortShopResultsForLengthRequest(string $prompt, string $shopSearchQuery, array $shopResults): array { if ( count($shopResults) < 2 || !$this->agentRunnerConfig->isShopResultLengthSortEnabled() || !$this->isShopResultLengthSortRequested($prompt . ' ' . $shopSearchQuery) ) { return $shopResults; } $hasLength = false; $decorated = []; foreach (array_values($shopResults) as $index => $product) { $length = $product instanceof ShopProductResult ? $this->extractShopProductLengthMeters($product) : null; $hasLength = $hasLength || $length !== null; $decorated[] = [ 'index' => $index, 'length' => $length, 'product' => $product, ]; } if (!$hasLength) { return $shopResults; } return $this->sortDecoratedShopProductsByLength($decorated); } /** * @param array $decorated * @return ShopProductResult[] */ private function sortDecoratedShopProductsByLength(array $decorated): array { usort($decorated, static function (array $a, array $b): int { if ($a['length'] === null && $b['length'] === null) { return $a['index'] <=> $b['index']; } if ($a['length'] === null) { return 1; } if ($b['length'] === null) { return -1; } $lengthCompare = $a['length'] <=> $b['length']; return $lengthCompare !== 0 ? $lengthCompare : ($a['index'] <=> $b['index']); }); return array_values(array_map( static fn(array $row): mixed => $row['product'], $decorated )); } private function isShopResultLengthSortRequested(string $text): bool { foreach ($this->agentRunnerConfig->getShopResultLengthSortTriggerPatterns() as $pattern) { if (@preg_match($pattern, $text) === 1) { return true; } } return false; } /** * @return array{type: string, value: float}|null */ private function resolveShopResultLengthFilter(string $prompt, string $shopSearchQuery): ?array { if (!$this->agentRunnerConfig->isShopResultLengthFilterEnabled()) { return null; } $text = trim($prompt . ' ' . $shopSearchQuery); if ($text === '') { return null; } foreach ($this->agentRunnerConfig->getShopResultMinLengthFilterPatterns() as $pattern) { $value = $this->extractShopLengthFilterPatternValue($pattern, $text); if ($value !== null) { return ['type' => 'min', 'value' => $value]; } } foreach ($this->agentRunnerConfig->getShopResultMaxLengthFilterPatterns() as $pattern) { $value = $this->extractShopLengthFilterPatternValue($pattern, $text); if ($value !== null) { return ['type' => 'max', 'value' => $value]; } } return null; } private function extractShopLengthFilterPatternValue(string $pattern, string $text): ?float { if (@preg_match($pattern, $text, $matches) !== 1) { return null; } $value = $matches['value'] ?? ($matches[1] ?? null); if (!is_scalar($value)) { return null; } $normalized = str_replace(',', '.', (string) $value); if (!is_numeric($normalized)) { return null; } return (float) $normalized; } /** * @param ShopProductResult[] $shopResults * @param array{type: string, value: float} $lengthFilter * @return ShopProductResult[] */ private function filterDirectShopAnswerResultsByLength(array $shopResults, array $lengthFilter): array { $decorated = []; foreach (array_values($shopResults) as $index => $product) { if (!$product instanceof ShopProductResult) { continue; } $length = $this->extractShopProductLengthMeters($product); if ($length === null) { continue; } if (($lengthFilter['type'] === 'min' && $length < $lengthFilter['value']) || ($lengthFilter['type'] === 'max' && $length > $lengthFilter['value']) ) { continue; } $decorated[] = [ 'index' => $index, 'length' => $length, 'product' => $product, ]; } return $this->sortDecoratedShopProductsByLength($decorated); } /** * @param array{type: string, value: float} $lengthFilter */ private function buildDirectShopAnswerLengthFilterNote(array $lengthFilter): string { $template = $lengthFilter['type'] === 'max' ? $this->agentRunnerConfig->getDirectShopResultAnswerMaxLengthFilterNote() : $this->agentRunnerConfig->getDirectShopResultAnswerMinLengthFilterNote(); return str_replace('{value}', $this->formatShopLengthFilterValue($lengthFilter['value']), $template); } private function formatShopLengthFilterValue(float $value): string { if (abs($value - round($value)) < 0.00001) { return (string) (int) round($value); } return rtrim(rtrim(str_replace('.', ',', sprintf('%.2F', $value)), '0'), ','); } private function extractShopProductLengthMeters(ShopProductResult $product): ?float { $text = trim(implode(' ', array_filter([ $product->name, $product->description, implode(' ', $product->highlights), $product->customFields, ]))); if ($text === '') { return null; } foreach ($this->agentRunnerConfig->getShopResultLengthSortValuePatterns() as $pattern) { if (@preg_match($pattern, $text, $matches) !== 1) { continue; } $value = $matches['value'] ?? ($matches[1] ?? null); if (!is_scalar($value)) { continue; } $normalized = str_replace(',', '.', (string) $value); if (is_numeric($normalized)) { return (float) $normalized; } } return null; } /** * @return string[] */ private function extractRequestedDirectProductTerms(string $prompt, string $shopSearchQuery = ''): array { $combined = trim($prompt . ' ' . $shopSearchQuery); if ($combined === '') { return []; } $terms = []; foreach ($this->agentRunnerConfig->getShopQueryProductAttributeCleanupProductTypeTerms() as $term) { if ($this->containsAllShopQueryTokens($combined, $term)) { $terms[] = $term; } } return array_values(array_unique($terms)); } private function containsAllShopQueryTokens(string $text, string $term): bool { $tokens = array_fill_keys($this->tokenizeShopQueryCandidate($text), true); $termTokens = $this->tokenizeShopQueryCandidate($term); if ($tokens === [] || $termTokens === []) { return false; } foreach ($termTokens as $termToken) { if (!isset($tokens[$termToken])) { return false; } } return true; } /** * @param string[] $requestedTerms */ private function shopProductPrimaryIdentityMatchesAnyDirectProductTerm(ShopProductResult $product, array $requestedTerms): bool { $primaryText = trim(implode(' ', array_filter([ $product->name, $product->url, ]))); return $this->textMatchesAnyDirectProductTerm($primaryText, $requestedTerms); } /** * @param string[] $requestedTerms */ private function shopProductMatchesAnyDirectProductTerm(ShopProductResult $product, array $requestedTerms): bool { $productText = trim(implode(' ', array_filter([ $product->name, $product->description, implode(' ', $product->highlights), $product->customFields, $product->url, ]))); return $this->textMatchesAnyDirectProductTerm($productText, $requestedTerms); } /** * @param string[] $requestedTerms */ private function textMatchesAnyDirectProductTerm(string $text, array $requestedTerms): bool { if (trim($text) === '') { return false; } foreach ($requestedTerms as $term) { if ($this->containsAllShopQueryTokens($text, $term)) { return true; } if ($this->containsAllShopQueryTokensWithCompoundPrefixes($text, $term)) { return true; } } return false; } private function containsAllShopQueryTokensWithCompoundPrefixes(string $text, string $term): bool { if (!$this->agentRunnerConfig->isDirectShopResultGuardCompoundPrefixMatchEnabled()) { return false; } $tokens = $this->tokenizeShopQueryCandidate($text); $termTokens = $this->tokenizeShopQueryCandidate($term); if ($tokens === [] || $termTokens === []) { return false; } $exactTokens = array_fill_keys($tokens, true); foreach ($termTokens as $termToken) { if (isset($exactTokens[$termToken])) { continue; } if (!$this->directProductTermAllowsCompoundPrefixMatch($termToken)) { return false; } $matchedPrefix = false; $termTokenLength = mb_strlen($termToken, 'UTF-8'); foreach ($tokens as $token) { if (mb_strlen($token, 'UTF-8') <= $termTokenLength) { continue; } if (mb_substr($token, 0, $termTokenLength, 'UTF-8') === $termToken) { $matchedPrefix = true; break; } } if (!$matchedPrefix) { return false; } } return true; } private function directProductTermAllowsCompoundPrefixMatch(string $termToken): bool { foreach ($this->agentRunnerConfig->getDirectShopResultGuardCompoundPrefixTerms() as $configuredTerm) { foreach ($this->tokenizeShopQueryCandidate($configuredTerm) as $configuredToken) { if ($termToken === $configuredToken) { return true; } } } return false; } /** * @param ShopProductResult[] $shopResults */ private function buildDeterministicDirectShopResultAnswer( string $prompt, array $shopResults, string $commerceIntent, bool $shopSearchAttempted, bool $shopSearchHadSystemFailure, string $shopSearchQuery ): string { if ( !$this->agentRunnerConfig->isDirectShopResultAnswerEnabled() || !$this->isCommerceIntent($commerceIntent) || !$shopSearchAttempted || $shopSearchHadSystemFailure || $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery) === [] ) { return ''; } $answerShopResults = $shopResults; $lengthFilter = $this->resolveShopResultLengthFilter($prompt, $shopSearchQuery); if ($lengthFilter !== null) { $answerShopResults = $this->filterDirectShopAnswerResultsByLength($answerShopResults, $lengthFilter); } if ($answerShopResults === []) { return $this->agentRunnerConfig->getDirectShopResultAnswerNoResultsMessage(); } $lines = [$this->agentRunnerConfig->getDirectShopResultAnswerIntro()]; if ($lengthFilter !== null) { $note = trim($this->buildDirectShopAnswerLengthFilterNote($lengthFilter)); if ($note !== '') { $lines[] = $note; } } if ($lengthFilter !== null || $this->isShopResultLengthSortRequested($prompt . ' ' . $shopSearchQuery)) { $note = trim($this->agentRunnerConfig->getDirectShopResultAnswerSortedByLengthNote()); if ($note !== '') { $lines[] = $note; } } $lines[] = ''; foreach ($this->buildDirectShopProductLines($answerShopResults, 'accessory_or_consumable') as $line) { $lines[] = $line; } return trim(implode("\n", $lines)); } /** * @param ShopProductResult[] $shopResults * @return string[] */ private function buildDirectShopProductLines(array $shopResults, string $requestedProductRole): array { $maxResults = max(1, $this->agentRunnerConfig->getDirectShopResultAnswerMaxResults()); $lines = []; $index = 1; foreach ($shopResults as $product) { if (!$product instanceof ShopProductResult) { continue; } $lines[] = $this->formatNoLlmShopProductLine($product, $index, $requestedProductRole); $index++; if (count($lines) >= $maxResults) { break; } } if ($lines === []) { return [$this->agentRunnerConfig->getNoLlmProductField('unreadable_results_message')]; } return $lines; } /** * Build a deterministic safety answer for environments where the LLM returns no tokens. * * This intentionally does not infer technical suitability from weak evidence. It only reports * reliable system state, shop hit metadata and the next safe action. * * @param string[] $knowledgeChunks * @param ShopProductResult[] $shopResults */ private function buildNoLlmFallbackAnswer( string $prompt, string $urlContent, array $knowledgeChunks, array $shopResults, string $commerceIntent, bool $shopSearchAttempted, bool $shopSearchHadSystemFailure, ?string $shopSearchFailureReason, string $knowledgeEvidenceState = 'unknown' ): string { $hasKnowledge = $this->isDirectKnowledgeEvidence($knowledgeEvidenceState) || ($knowledgeEvidenceState === 'unknown' && ($knowledgeChunks !== [] || trim($urlContent) !== '')); $hasShopResults = $shopResults !== []; $isCommerceIntent = $this->isCommerceIntent($commerceIntent); if ($hasShopResults) { return $this->buildNoLlmShopFallbackAnswer( prompt: $prompt, hasKnowledge: $hasKnowledge, shopResults: $shopResults ); } if ($shopSearchHadSystemFailure) { return $this->buildNoLlmShopUnavailableAnswer( hasKnowledge: $hasKnowledge, reason: $shopSearchFailureReason ); } if ($isCommerceIntent && $shopSearchAttempted) { return $this->buildNoLlmNoShopResultsAnswer($hasKnowledge); } if ($hasKnowledge) { return $this->agentRunnerConfig->getNoLlmFallbackKnowledgeOnlyMessage(); } return $this->agentRunnerConfig->getNoLlmFallbackNoDataMessage(); } /** * @param ShopProductResult[] $shopResults */ private function buildNoLlmShopFallbackAnswer(string $prompt, bool $hasKnowledge, array $shopResults): string { $intro = $hasKnowledge ? $this->agentRunnerConfig->getNoLlmFallbackShopWithKnowledgeMessage() : $this->agentRunnerConfig->getNoLlmFallbackShopOnlyMessage(); $requestedProductRole = $this->resolveNoLlmRequestedProductRole($prompt); $lines = [$intro]; if ($this->hasOnlyNoLlmAccessoryResultsForMainDeviceRequest($requestedProductRole, $shopResults)) { $lines[] = $this->agentRunnerConfig->getNoLlmFallbackAccessoryOnlyForMainDeviceMessage(); } $lines[] = ''; foreach ($this->buildNoLlmShopProductLines($shopResults, $requestedProductRole) as $line) { $lines[] = $line; } $escalation = trim($this->agentRunnerConfig->getNoLlmFallbackEscalationMessage()); if ($escalation !== '') { $lines[] = ''; $lines[] = $escalation; } return trim(implode("\n", $lines)); } private function buildNoLlmShopUnavailableAnswer(bool $hasKnowledge, ?string $reason): string { $reason = $this->normalizeOneLine((string) $reason); $message = $hasKnowledge ? $this->agentRunnerConfig->getNoLlmFallbackShopUnavailableWithKnowledgeMessage() : $this->agentRunnerConfig->getNoLlmFallbackShopUnavailableNoKnowledgeMessage(); if ($reason !== '') { $message = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('unavailable_reason_template'), [ 'message' => $message, 'reason' => $reason, ]); } return trim($message); } private function buildNoLlmNoShopResultsAnswer(bool $hasKnowledge): string { return $hasKnowledge ? $this->agentRunnerConfig->getNoLlmFallbackNoShopResultsWithKnowledgeMessage() : $this->agentRunnerConfig->getNoLlmFallbackNoShopResultsNoKnowledgeMessage(); } /** * @param ShopProductResult[] $shopResults * @return string[] */ private function buildNoLlmShopProductLines(array $shopResults, string $requestedProductRole): array { $maxResults = max(1, $this->agentRunnerConfig->getNoLlmFallbackMaxShopResults()); $lines = []; $index = 1; foreach ($shopResults as $product) { if (!$product instanceof ShopProductResult) { continue; } $lines[] = $this->formatNoLlmShopProductLine($product, $index, $requestedProductRole); $index++; if (count($lines) >= $maxResults) { break; } } if ($lines === []) { return [$this->agentRunnerConfig->getNoLlmProductField('unreadable_results_message')]; } return $lines; } private function formatNoLlmShopProductLine(ShopProductResult $product, int $index, string $requestedProductRole): string { $parts = []; $productRole = $this->resolveNoLlmShopProductRole($product); $name = $this->normalizeOneLine($product->name); $parts[] = $name !== '' ? $name : $this->agentRunnerConfig->getNoLlmProductField('unnamed_product'); if ($product->productNumber !== null && trim($product->productNumber) !== '') { $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('product_number_template'), ['value' => $this->normalizeOneLine($product->productNumber)]); } if ($product->manufacturer !== null && trim($product->manufacturer) !== '') { $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('manufacturer_template'), ['value' => $this->normalizeOneLine($product->manufacturer)]); } if ($product->price !== null && trim($product->price) !== '') { $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('price_template'), ['value' => $this->normalizeOneLine($product->price)]); } if ($product->available !== null) { $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('availability_template'), ['value' => $product->available ? $this->agentRunnerConfig->getNoLlmProductField('availability_yes') : $this->agentRunnerConfig->getNoLlmProductField('availability_no')]); } if ($product->url !== null && trim($product->url) !== '') { $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('url_template'), ['value' => $this->normalizeOneLine($product->url)]); } if ($requestedProductRole === 'main_device_or_system' && $productRole === 'accessory_or_consumable') { $parts[] = $this->agentRunnerConfig->getNoLlmProductField('incompatible_role_note'); } return $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('line_template'), [ 'index' => (string) $index, 'parts' => implode($this->agentRunnerConfig->getNoLlmProductField('separator'), $parts), ]); } /** * @param ShopProductResult[] $shopResults */ private function hasOnlyNoLlmAccessoryResultsForMainDeviceRequest(string $requestedProductRole, array $shopResults): bool { if ($requestedProductRole !== 'main_device_or_system') { return false; } $seenProducts = 0; foreach ($shopResults as $product) { if (!$product instanceof ShopProductResult) { continue; } $seenProducts++; if ($this->resolveNoLlmShopProductRole($product) !== 'accessory_or_consumable') { return false; } } return $seenProducts > 0; } private function resolveNoLlmRequestedProductRole(string $prompt): string { $normalized = mb_strtolower($prompt, 'UTF-8'); if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords())) { return 'accessory_or_consumable'; } if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords())) { return 'main_device_or_system'; } return 'unknown'; } private function resolveNoLlmShopProductRole(ShopProductResult $product): string { $normalized = mb_strtolower($this->normalizeOneLine(implode(' ', [ $product->name, (string) $product->description, (string) $product->customFields, implode(' ', $product->highlights), ])), 'UTF-8'); if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords())) { return 'accessory_or_consumable'; } return 'unknown'; } /** * @param string[] $terms */ private function containsAnyConfiguredTerm(string $haystack, array $terms): bool { foreach ($terms as $term) { $term = mb_strtolower(trim($term), 'UTF-8'); if ($term !== '' && str_contains($haystack, $term)) { return true; } } return false; } /** * Distinguish semantic nearest-neighbor retrieval hits from direct factual evidence. * * Vector retrieval can return useful context even when the essential user term is not * present. Those hits should stay visible as RAG hits, but they must not be counted as * "fachlich belegt" unless at least one salient request term or configured synonym * appears in the retrieved knowledge or user-provided URL content. * * @param string[] $knowledgeChunks */ private function resolveKnowledgeEvidenceState(string $prompt, array $knowledgeChunks, string $urlContent): string { if ($knowledgeChunks === [] && trim($urlContent) === '') { return 'none'; } if (trim($urlContent) !== '') { return 'direct'; } $needles = $this->buildRagEvidenceNeedles($prompt); if ($needles === []) { // No meaningful term could be extracted. Preserve the previous behavior for // very short follow-ups instead of hiding potentially valid context. return 'direct'; } $haystack = $this->normalizeRagEvidenceText(implode("\n\n", array_map('strval', $knowledgeChunks))); $isAggregateQuery = $this->isAggregateRagEvidenceQuery($prompt); if ( $isAggregateQuery && !$this->containsAnyRagEvidencePattern($haystack, $this->agentRunnerConfig->getRagEvidenceAggregateAnswerEvidencePatterns()) ) { return 'aggregate_missing'; } if ( $isAggregateQuery && !$this->containsAnyRagEvidenceTerm($haystack, $this->agentRunnerConfig->getRagEvidenceAggregateEvidenceTerms()) ) { return 'aggregate_missing'; } foreach ($needles as $needleGroup) { foreach ($needleGroup as $needle) { if ($this->containsRagEvidenceTerm($haystack, $needle)) { return 'direct'; } } } return 'weak'; } private function isDirectKnowledgeEvidence(string $knowledgeEvidenceState): bool { return $knowledgeEvidenceState === 'direct'; } private function resolveRagEvidenceConfidenceLabel(string $knowledgeEvidenceState): string { return match ($knowledgeEvidenceState) { 'direct' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct'), 'aggregate_missing' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing'), 'weak' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('weak'), default => $this->agentRunnerConfig->getProductionUiConfidenceLabel('default'), }; } private function resolveRagEvidenceShopCheckConfidenceLabel(string $knowledgeEvidenceState): string { return match ($knowledgeEvidenceState) { 'direct' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct_shop_check'), 'aggregate_missing' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_shop_check'), 'weak' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('weak_shop_check'), default => $this->agentRunnerConfig->getProductionUiConfidenceLabel('default_shop_check'), }; } private function isAggregateRagEvidenceQuery(string $prompt): bool { $normalizedPrompt = $this->normalizeRagEvidenceText($prompt); if ($normalizedPrompt === '') { return false; } foreach ($this->agentRunnerConfig->getRagEvidenceAggregateQueryPatterns() as $pattern) { if (@preg_match($pattern, $normalizedPrompt) === 1) { return true; } } return false; } /** * @param string[] $terms */ private function containsAnyRagEvidenceTerm(string $haystack, array $terms): bool { foreach ($terms as $term) { if ($this->containsRagEvidenceTerm($haystack, $term)) { return true; } } return false; } /** * @param string[] $patterns */ private function containsAnyRagEvidencePattern(string $haystack, array $patterns): bool { foreach ($patterns as $pattern) { if (@preg_match($pattern, $haystack) === 1) { return true; } } return false; } /** * @return array */ private function buildRagEvidenceNeedles(string $prompt): array { $normalizedPrompt = $this->normalizeRagEvidenceText($prompt); $stopTerms = []; foreach ($this->getRagEvidenceCleanupStopTerms() as $term) { $term = $this->normalizeRagEvidenceText($term); if ($term !== '') { $stopTerms[$term] = true; } } preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}\-]{1,}/u', $normalizedPrompt, $matches); $tokens = $matches[0] ?? []; $groups = []; $synonyms = $this->normalizedRagEvidenceSynonyms(); foreach ($tokens as $token) { $token = trim((string) $token); if ($token === '' || isset($stopTerms[$token]) || mb_strlen($token, 'UTF-8') < 3) { continue; } $group = $synonyms[$token] ?? [$token]; $group = array_values(array_unique(array_filter(array_map( fn (string $item): string => $this->normalizeRagEvidenceText($item), $group )))); if ($group !== []) { $groups[] = $group; } } return $groups; } /** @return string[] */ private function getRagEvidenceCleanupStopTerms(): array { return $this->mergeUniqueStrings( $this->languageCleanupConfig->getStopWordsForProfile($this->agentRunnerConfig->getRagEvidenceCleanupProfile()), $this->agentRunnerConfig->getRagEvidenceStopTerms() ); } /** * @param string[] $left * @param string[] $right * @return string[] */ private function mergeUniqueStrings(array $left, array $right): array { $out = []; foreach (array_merge($left, $right) as $item) { $item = trim((string) $item); if ($item === '' || isset($out[$item])) { continue; } $out[$item] = $item; } return array_values($out); } /** * @return array */ private function normalizedRagEvidenceSynonyms(): array { $out = []; foreach ($this->agentRunnerConfig->getRagEvidenceSynonyms() as $key => $items) { $key = $this->normalizeRagEvidenceText((string) $key); if ($key === '') { continue; } $terms = []; foreach ($items as $item) { $item = $this->normalizeRagEvidenceText((string) $item); if ($item !== '' && !in_array($item, $terms, true)) { $terms[] = $item; } } if (!in_array($key, $terms, true)) { $terms[] = $key; } $out[$key] = $terms; } return $out; } private function containsRagEvidenceTerm(string $haystack, string $needle): bool { $needle = $this->normalizeRagEvidenceText($needle); if ($haystack === '' || $needle === '') { return false; } $pattern = '/(?languageCleanupConfig->normalizeDashEquivalents($value); $value = $this->languageCleanupConfig->transliterateToAscii($value); $value = preg_replace('/\s+/u', ' ', $value) ?? $value; return trim($value); } /** * @param string[] $sources */ private function emitSources(array $sources, string $prefix): string { return $this->systemMsg($prefix . implode(' ', $sources), 'info'); } /** * @param string[] $sources */ private function addSource(array &$sources, string $label): void { $badge = $this->badge($label); if (!in_array($badge, $sources, true)) { $sources[] = $badge; } } /** * @param string[] $notices */ private function buildHistoryResponse(string $fullOutput, array $notices): string { $parts = []; foreach ($notices as $notice) { $notice = trim($notice); if ($notice !== '') { $parts[] = $notice; } } $fullOutput = trim($fullOutput); if ($fullOutput !== '') { $parts[] = $fullOutput; } else { $noLlmMessage = $this->plainTextFromHtml($this->agentRunnerConfig->getNoLlmDataReceivedMessage()); if ($noLlmMessage === '') { $noLlmMessage = $this->agentRunnerConfig->getProductionUiText('no_llm_history_default'); } $parts[] = $this->renderAgentTemplate( $this->agentRunnerConfig->getHistoryResponseSystemNoticeTemplate(), ['message' => $noLlmMessage] ); } return trim(implode("\n\n", $parts)); } private function buildHistoryNotice(string $title, ?string $detail): string { $title = $this->normalizeOneLine($this->plainTextFromHtml($title)); $detail = $this->normalizeOneLine($this->plainTextFromHtml((string) $detail)); if ($title === '') { $title = $this->agentRunnerConfig->getProductionUiText('history_notice_default_title'); } if ($detail === '') { return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('history_notice_without_detail'), ['title' => $title]); } if (mb_strlen($detail, 'UTF-8') > 500) { $detail = rtrim(mb_substr($detail, 0, 497, 'UTF-8')) . '...'; } return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('history_notice_with_detail'), [ 'title' => $title, 'detail' => $detail, ]); } private function plainTextFromHtml(string $value): string { $value = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $value = preg_replace('/\s+/u', ' ', $value) ?? $value; return trim($value); } private function resolveShopCountModeForMeta( string $commerceIntent, bool $shopSearchAttempted, bool $shopSearchHadSystemFailure, bool $shopSearchSkippedBecauseNoQuery = false ): string { if ($shopSearchHadSystemFailure) { return 'unavailable'; } if ($shopSearchAttempted) { return 'count'; } if ($shopSearchSkippedBecauseNoQuery) { return 'not_resolved'; } if ($this->isCommerceIntent($commerceIntent)) { return 'loading'; } return 'not_requested'; } /** * @param string[] $sourceLabels */ private function buildProductionUiMetaMessage( string $stageLabel, ?int $ragCount, ?int $shopCount, string $shopCountMode, array $sourceLabels, string $confidenceLabel, bool $completed = false ): string { $state = $completed ? 'completed' : 'running'; $ragLabel = $ragCount === null ? $this->agentRunnerConfig->getProductionUiText('rag_hits_checking') : $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('rag_hits_count'), ['count' => (string) max(0, $ragCount)]); $shopLabel = match ($shopCountMode) { 'count' => $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_hits_count'), ['count' => (string) max(0, (int) $shopCount)]), 'loading' => $this->agentRunnerConfig->getProductionUiText('shop_hits_loading'), 'unavailable' => $this->agentRunnerConfig->getProductionUiText('shop_hits_unavailable'), 'not_resolved' => $this->agentRunnerConfig->getProductionUiText('shop_hits_no_query'), default => $this->agentRunnerConfig->getProductionUiText('shop_hits_not_requested'), }; $statusLabel = $completed ? $this->agentRunnerConfig->getProductionUiText('status_completed') : $this->agentRunnerConfig->getProductionUiText('status_running'); $sources = $this->formatProductionUiSourceLabels($sourceLabels); $html = '
' . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('run_status_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
' . htmlspecialchars($stageLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
' . '' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . '' . htmlspecialchars($ragLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . '' . htmlspecialchars($shopLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . '' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('evidence_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . '
'; if ($sources !== []) { $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('data_basis_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; foreach ($sources as $source) { $html .= '' . htmlspecialchars($source, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; } $html .= '
'; } else { $emptySourceLabel = $completed ? $this->agentRunnerConfig->getProductionUiText('data_basis_empty_completed') : $this->agentRunnerConfig->getProductionUiText('data_basis_empty_running'); $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('data_basis_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . htmlspecialchars($emptySourceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; } $html .= '
'; return $html; } private function resolveProductionUiConfidenceLabel( bool $hasKnowledge, bool $isCommerceIntent, bool $shopSearchAttempted, bool $hasShopResults, bool $shopSearchHadSystemFailure, string $knowledgeEvidenceState = 'unknown' ): string { if ($knowledgeEvidenceState === 'aggregate_missing' && !$hasShopResults) { return $shopSearchHadSystemFailure ? $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_shop_unavailable') : $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_no_count'); } if ($shopSearchHadSystemFailure) { return $hasKnowledge ? $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_unavailable_with_knowledge') : $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_unavailable'); } if ($hasKnowledge && $hasShopResults) { return $this->agentRunnerConfig->getProductionUiConfidenceLabel('rag_and_shop'); } if (!$hasKnowledge && $hasShopResults) { return $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_only'); } if ($hasKnowledge && $shopSearchAttempted) { return $this->agentRunnerConfig->getProductionUiConfidenceLabel('rag_no_shop_hits'); } if ($hasKnowledge) { return $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct'); } if ($isCommerceIntent || $shopSearchAttempted) { return $this->agentRunnerConfig->getProductionUiConfidenceLabel('no_reliable_data'); } return $this->agentRunnerConfig->getProductionUiConfidenceLabel('no_reliable_hits'); } /** * @param string[] $sourceLabels * @return string[] */ private function formatProductionUiSourceLabels(array $sourceLabels): array { $labels = []; foreach ($sourceLabels as $label) { // Source labels are stored as badge HTML for the legacy "Genutzte Quellen" line. // The production UI data-basis chips must render plain labels, otherwise the // nested badge markup is escaped and shown as visible text. $label = $this->plainTextFromHtml((string) $label); if ($label === '') { continue; } if ($label === $this->plainTextFromHtml($this->agentRunnerConfig->getShopSystemSourceLabel())) { $label = $this->agentRunnerConfig->getProductionUiText('live_shop_source_plain_label'); } if (!in_array($label, $labels, true)) { $labels[] = $label; } } return $labels; } /** * @param ShopProductResult[] $shopResults */ private function buildShopProductCardsMessage(array $shopResults, string $query, bool $usedRepair): string { $maxCards = max(1, $this->agentRunnerConfig->getProductionUiShopResultsMaxCards()); $visibleResults = array_slice($shopResults, 0, $maxCards); $totalCount = count($shopResults); $query = $this->normalizeOneLine($query); $summary = $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_results_summary'), ['count' => (string) $totalCount]); if ($totalCount > $maxCards) { $summary .= $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_results_top_displayed_suffix'), ['max' => (string) $maxCards]); } if ($usedRepair) { $summary .= $this->agentRunnerConfig->getProductionUiTemplate('shop_results_repair_suffix'); } $html = '
' . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_results_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_results_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
' . htmlspecialchars($summary, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; if ($query !== '') { $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('evaluated_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; } $html .= '
'; foreach ($visibleResults as $product) { if (!$product instanceof ShopProductResult) { continue; } $html .= $this->buildShopProductCard($product, $query); } $html .= '
'; return $html; } private function buildShopProductCard(ShopProductResult $product, string $query): string { $name = $this->normalizeOneLine($product->name) ?: $this->agentRunnerConfig->getProductionUiText('unnamed_product'); $productNumber = $this->normalizeOneLine((string) $product->productNumber); $manufacturer = $this->normalizeOneLine((string) $product->manufacturer); $price = $this->normalizeOneLine((string) $product->price); $url = $this->normalizeOneLine((string) $product->url); $availability = $this->formatProductAvailability($product->available); $relevance = $this->buildProductRelevanceLabel($product, $query); $html = '
' . '
'; if ($url !== '') { $html .= '' . htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; } else { $html .= htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } $html .= '
'; $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('product_number_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . htmlspecialchars($productNumber !== '' ? $productNumber : $this->agentRunnerConfig->getProductionUiText('field_not_provided'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('price_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . htmlspecialchars($price !== '' ? $price : $this->agentRunnerConfig->getProductionUiText('field_not_provided'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('availability_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; if ($manufacturer !== '') { $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('manufacturer_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; } $html .= '
' . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('relevance_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . htmlspecialchars($relevance, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
'; return $html; } private function formatProductAvailability(?bool $available): string { return match ($available) { true => $this->agentRunnerConfig->getProductionUiText('availability_yes'), false => $this->agentRunnerConfig->getProductionUiText('availability_no'), default => $this->agentRunnerConfig->getProductionUiText('availability_unknown'), }; } private function buildProductRelevanceLabel(ShopProductResult $product, string $query): string { $matchedQueries = []; foreach ($product->matchedQueries as $matchedQuery) { $matchedQuery = $this->normalizeOneLine((string) $matchedQuery); if ($matchedQuery !== '' && !in_array($matchedQuery, $matchedQueries, true)) { $matchedQueries[] = $matchedQuery; } } if ($matchedQueries !== []) { return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_matched_queries'), [ 'queries' => implode(', ', array_slice($matchedQueries, 0, 3)), ]); } foreach ($product->highlights as $highlight) { $highlight = $this->normalizeOneLine($this->plainTextFromHtml((string) $highlight)); if ($highlight !== '') { return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_highlight'), [ 'highlight' => mb_substr($highlight, 0, 140, 'UTF-8'), ]); } } $matchSource = $this->normalizeOneLine((string) $product->matchSource); if ($matchSource !== '') { return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_match_source'), ['source' => $matchSource]); } if ($query !== '') { return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_query'), ['query' => $query]); } return $this->agentRunnerConfig->getProductionUiTemplate('relevance_default'); } /** * @param ShopProductResult[] $shopResults */ private function buildFollowUpActionsMessage( bool $isCommerceIntent, bool $hasShopResults, bool $hasKnowledge, bool $shopSearchHadSystemFailure, array $shopResults, string $shopSearchQuery, string $answerText ): string { if (!$this->agentRunnerConfig->isProductionUiFollowUpActionsEnabled()) { return ''; } $context = $this->buildFollowUpActionContext( shopResults: $shopResults, shopSearchQuery: $shopSearchQuery, answerText: $answerText ); $actions = []; $seenActionKeys = []; if ($hasShopResults) { $this->appendFollowUpActions( actions: $actions, seenActionKeys: $seenActionKeys, items: $this->agentRunnerConfig->getProductionUiFollowUpActions('shop_results'), context: $context ); } elseif ($isCommerceIntent && !$shopSearchHadSystemFailure && $context['shop_query'] !== '') { $this->appendFollowUpActions( actions: $actions, seenActionKeys: $seenActionKeys, items: $this->agentRunnerConfig->getProductionUiFollowUpActions('commerce'), context: $context ); } if ($hasKnowledge) { $this->appendFollowUpActions( actions: $actions, seenActionKeys: $seenActionKeys, items: $this->agentRunnerConfig->getProductionUiFollowUpActions('knowledge'), context: $context ); } if ($actions === []) { return ''; } $html = '
' . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('followup_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('followup_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
'; foreach ($actions as $action) { $html .= ''; } $html .= '
'; return $html; } /** * @param ShopProductResult[] $shopResults * @return array{shop_query:string, shop_price_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array} */ private function buildFollowUpActionContext(array $shopResults, string $shopSearchQuery, string $answerText): array { $plainAnswerText = $this->normalizeOneLine($this->plainTextFromHtml($answerText)); $roleCounts = $this->buildFollowUpActionRoleCounts($shopResults); $displayedProducts = $this->buildFollowUpActionDisplayedProducts($shopResults, $plainAnswerText); $displayedRoleCounts = $this->buildFollowUpActionProductRoleCounts($displayedProducts); if (array_sum($displayedRoleCounts) > 0) { $roleCounts = $displayedRoleCounts; } $normalizedShopQuery = $this->normalizeOneLine($shopSearchQuery); return [ 'shop_query' => $normalizedShopQuery, 'shop_price_query' => $this->buildFollowUpActionPriceQuery($displayedProducts, $plainAnswerText, $normalizedShopQuery), 'answer_has_price' => $this->followUpActionAnswerAlreadyContainsPrice($plainAnswerText), 'answer_text' => $plainAnswerText, 'answer_anchor' => $this->buildFollowUpActionAnswerAnchor($plainAnswerText), 'answer_detail_score' => $this->calculateFollowUpActionAnswerDetailScore($plainAnswerText), 'role_counts' => $roleCounts, ]; } /** * @return array */ private function emptyFollowUpActionRoleCounts(): array { return [ ProductRoleResolver::ROLE_MAIN_DEVICE => 0, ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE => 0, ProductRoleResolver::ROLE_AMBIGUOUS_MIXED => 0, ProductRoleResolver::ROLE_UNKNOWN => 0, ]; } /** * @param ShopProductResult[] $shopResults * @return array */ private function buildFollowUpActionRoleCounts(array $shopResults): array { $roleCounts = $this->emptyFollowUpActionRoleCounts(); foreach ($shopResults as $product) { if (!$product instanceof ShopProductResult) { continue; } $this->countFollowUpActionProductRole($roleCounts, $product); } return $roleCounts; } /** * @param ShopProductResult[] $shopResults * @return ShopProductResult[] */ private function buildFollowUpActionDisplayedProducts(array $shopResults, string $answerText): array { if ($answerText === '') { return []; } $products = []; $seen = []; foreach ($shopResults as $product) { if (!$product instanceof ShopProductResult) { continue; } if (!$this->isFollowUpActionProductDisplayedInAnswer($product, $answerText)) { continue; } $key = mb_strtolower($this->normalizeOneLine(($product->productNumber ?? '') . ' ' . $product->name), 'UTF-8'); if ($key === '' || isset($seen[$key])) { continue; } $seen[$key] = true; $products[] = $product; } return $products; } /** * @param ShopProductResult[] $products * @return array */ private function buildFollowUpActionProductRoleCounts(array $products): array { $roleCounts = $this->emptyFollowUpActionRoleCounts(); foreach ($products as $product) { if (!$product instanceof ShopProductResult) { continue; } $this->countFollowUpActionProductRole($roleCounts, $product); } return $roleCounts; } /** * @param array $roleCounts */ private function countFollowUpActionProductRole(array &$roleCounts, ShopProductResult $product): void { $role = $this->resolveFollowUpActionShopProductRole($product); if (!array_key_exists($role, $roleCounts)) { $role = ProductRoleResolver::ROLE_UNKNOWN; } ++$roleCounts[$role]; } private function isFollowUpActionProductDisplayedInAnswer(ShopProductResult $product, string $answerText): bool { $normalizedAnswer = mb_strtolower($this->normalizeOneLine($answerText), 'UTF-8'); if ($normalizedAnswer === '') { return false; } $productNumber = $this->normalizeOneLine((string) $product->productNumber); if ($productNumber !== '' && mb_strlen($productNumber, 'UTF-8') >= 3 && str_contains($normalizedAnswer, mb_strtolower($productNumber, 'UTF-8'))) { return true; } $productName = mb_strtolower($this->normalizeOneLine($product->name), 'UTF-8'); if ($productName === '' || mb_strlen($productName, 'UTF-8') < 16) { return false; } preg_match_all('/[\p{L}\p{N}]+/u', $productName, $matches); $tokens = array_values(array_unique($matches[0] ?? [])); if (count($tokens) < 3) { return false; } return str_contains($normalizedAnswer, $productName); } /** * @param ShopProductResult[] $displayedProducts */ private function buildFollowUpActionPriceQuery(array $displayedProducts, string $answerText, string $fallbackShopQuery): string { $numberedProducts = $this->filterFollowUpActionDisplayedProductsWithNumber($displayedProducts, $answerText); if (count($numberedProducts) === 1) { return $this->buildFollowUpActionProductQuery($numberedProducts[0]); } $focusedProducts = $this->filterFollowUpActionPrimaryDisplayedProducts($displayedProducts); if (count($focusedProducts) === 1) { return $this->buildFollowUpActionProductQuery($focusedProducts[0]); } if (count($displayedProducts) === 1) { return $this->buildFollowUpActionProductQuery($displayedProducts[0]); } $answerAnchor = $this->buildFollowUpActionAnswerAnchor($answerText); if ($answerAnchor !== '') { return $answerAnchor; } return $fallbackShopQuery; } /** * @param ShopProductResult[] $products * @return ShopProductResult[] */ private function filterFollowUpActionDisplayedProductsWithNumber(array $products, string $answerText): array { $numberedProducts = []; $normalizedAnswer = mb_strtolower($this->normalizeOneLine($answerText), 'UTF-8'); foreach ($products as $product) { if (!$product instanceof ShopProductResult) { continue; } $productNumber = $this->normalizeOneLine((string) $product->productNumber); if ($productNumber === '' || mb_strlen($productNumber, 'UTF-8') < 3) { continue; } if (str_contains($normalizedAnswer, mb_strtolower($productNumber, 'UTF-8'))) { $numberedProducts[] = $product; } } return $numberedProducts; } /** * @param ShopProductResult[] $products * @return ShopProductResult[] */ private function filterFollowUpActionPrimaryDisplayedProducts(array $products): array { $mainDevices = []; foreach ($products as $product) { if (!$product instanceof ShopProductResult) { continue; } if ($this->resolveFollowUpActionShopProductRole($product) === ProductRoleResolver::ROLE_MAIN_DEVICE) { $mainDevices[] = $product; } } return $mainDevices; } private function buildFollowUpActionProductQuery(ShopProductResult $product): string { $parts = [$product->name]; $productNumber = $this->normalizeOneLine((string) $product->productNumber); if ($productNumber !== '') { $parts[] = $productNumber; } return $this->normalizeOneLine(implode(' ', array_filter($parts, static fn(string $part): bool => trim($part) !== ''))); } private function buildFollowUpActionAnswerAnchor(string $answerText): string { $anchors = []; $modelAnchor = $this->referenceAnchorExtractor->extractFirstProductModelAnchor($answerText); if ($modelAnchor !== '') { $anchors[] = $modelAnchor; } $measurementAnchor = $this->referenceAnchorExtractor->extractFirstMeasurementValueAnchor($answerText); if ($measurementAnchor !== '') { $anchors[] = $measurementAnchor; } return $this->normalizeOneLine(implode(' ', array_values(array_unique($anchors)))); } private function calculateFollowUpActionAnswerDetailScore(string $answerText): int { $score = 0; if ($this->referenceAnchorExtractor->extractFirstProductModelAnchor($answerText) !== '') { ++$score; } if ($this->referenceAnchorExtractor->extractFirstMeasurementValueAnchor($answerText) !== '') { ++$score; } return $score; } private function resolveFollowUpActionShopProductRole(ShopProductResult $product): string { $primaryText = mb_strtolower($this->normalizeOneLine(implode(' ', [ $product->name, (string) $product->productNumber, ])), 'UTF-8'); $hasPrimaryAccessory = $this->containsAnyConfiguredTerm($primaryText, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords()); $hasPrimaryDevice = $this->containsAnyConfiguredTerm($primaryText, $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords()); if ($hasPrimaryAccessory && !$hasPrimaryDevice) { return ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE; } if ($hasPrimaryDevice && !$hasPrimaryAccessory) { return ProductRoleResolver::ROLE_MAIN_DEVICE; } if ($hasPrimaryAccessory && $hasPrimaryDevice) { return ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE; } $corpus = mb_strtolower($this->normalizeOneLine(implode(' ', [ $product->name, (string) $product->description, (string) $product->customFields, implode(' ', $product->highlights), ])), 'UTF-8'); $hasAccessory = $this->containsAnyConfiguredTerm($corpus, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords()); $hasDevice = $this->containsAnyConfiguredTerm($corpus, $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords()); if ($hasAccessory && !$hasDevice) { return ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE; } if ($hasDevice && !$hasAccessory) { return ProductRoleResolver::ROLE_MAIN_DEVICE; } if ($hasAccessory && $hasDevice) { return ProductRoleResolver::ROLE_AMBIGUOUS_MIXED; } return ProductRoleResolver::ROLE_UNKNOWN; } private function followUpActionAnswerAlreadyContainsPrice(string $answerText): bool { return preg_match('/(?:\bpreis\b.{0,24}\d+[,.]\d{2}|\d+[,.]\d{2}\s*(?:€|eur)\b|(?:€|eur)\s*\d+[,.]\d{2})/iu', $answerText) === 1; } /** * @param array> $actions * @param array $seenActionKeys * @param array> $items * @param array{shop_query:string, shop_price_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array} $context */ private function appendFollowUpActions(array &$actions, array &$seenActionKeys, array $items, array $context): void { foreach ($items as $item) { $label = trim((string) ($item['label'] ?? '')); $actionPrompt = trim((string) ($item['prompt'] ?? '')); if ($label === '' || $actionPrompt === '') { continue; } if (!$this->shouldShowFollowUpAction($item, $context)) { continue; } $actionPrompt = $this->renderFollowUpActionPrompt($actionPrompt, $context); if ($actionPrompt === '') { continue; } if (!$this->shouldShowRenderedFollowUpAction($item, $context, $actionPrompt)) { continue; } $key = mb_strtolower($label . "\n" . $actionPrompt, 'UTF-8'); if (isset($seenActionKeys[$key])) { continue; } $seenActionKeys[$key] = true; $actions[] = [ 'label' => $label, 'prompt' => $actionPrompt, ]; } } /** * @param array $item * @param array{shop_query:string, shop_price_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array} $context */ private function shouldShowFollowUpAction(array $item, array $context): bool { $actionType = isset($item['action_type']) && is_scalar($item['action_type']) ? trim((string) $item['action_type']) : ''; $targetRole = isset($item['target_role']) && is_scalar($item['target_role']) ? trim((string) $item['target_role']) : ''; if ($this->followUpActionAnswerMatchesAnyConfiguredPattern($context['answer_text'], $item['hide_when_answer_matches_any'] ?? [])) { return false; } $hideAtDetailScore = $this->optionalFollowUpActionInt($item, 'hide_when_answer_detail_score_at_least'); if ($hideAtDetailScore !== null && $context['answer_detail_score'] >= $hideAtDetailScore) { return false; } if ($this->optionalFollowUpActionBool($item, 'requires_answer_anchor') && $context['answer_anchor'] === '') { return false; } if ($actionType === 'shop_search') { return $context['shop_query'] !== ''; } if ($actionType === 'price_details') { return $context['shop_price_query'] !== '' && !$context['answer_has_price']; } if ($actionType === 'role_filter') { return $context['shop_query'] !== '' && $this->isFollowUpRoleFilterMeaningful($targetRole, $context['role_counts']); } if ($actionType === 'technical_details') { return $context['answer_anchor'] !== ''; } return true; } /** * @param array $item * @param array{shop_query:string, shop_price_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array} $context */ private function shouldShowRenderedFollowUpAction(array $item, array $context, string $renderedPrompt): bool { if (!$this->optionalFollowUpActionBool($item, 'hide_when_material_query_matches_current')) { return true; } $currentQuery = $this->normalizeShopQueryForComparison($context['shop_query']); if ($currentQuery === '') { return true; } $materialQueryTemplate = isset($item['material_query_template']) && is_scalar($item['material_query_template']) ? trim((string) $item['material_query_template']) : ''; $materialQuery = $materialQueryTemplate !== '' ? $this->renderFollowUpActionPrompt($materialQueryTemplate, $context) : $renderedPrompt; $materialQuery = $this->normalizeShopQueryForComparison($materialQuery); if ($materialQuery === '') { return true; } return $materialQuery !== $currentQuery; } private function optionalFollowUpActionBool(array $item, string $key): bool { if (!array_key_exists($key, $item)) { return false; } $value = $item[$key]; if (is_bool($value)) { return $value; } if (is_scalar($value)) { $normalized = mb_strtolower(trim((string) $value), 'UTF-8'); return in_array($normalized, ['1', 'true', 'yes', 'on'], true); } return false; } private function optionalFollowUpActionInt(array $item, string $key): ?int { if (!isset($item[$key]) || !is_scalar($item[$key])) { return null; } $value = trim((string) $item[$key]); if ($value === '' || !preg_match('/^-?\d+$/', $value)) { return null; } return (int) $value; } /** * @param mixed $patterns */ private function followUpActionAnswerMatchesAnyConfiguredPattern(string $answerText, mixed $patterns): bool { if (!is_array($patterns) || $answerText === '') { return false; } foreach ($patterns as $pattern) { if (!is_scalar($pattern)) { continue; } $pattern = trim((string) $pattern); if ($pattern === '') { continue; } if (@preg_match($pattern, '') === false) { continue; } if (@preg_match($pattern, $answerText) === 1) { return true; } } return false; } /** * @param array $roleCounts */ private function isFollowUpRoleFilterMeaningful(string $targetRole, array $roleCounts): bool { if (!isset($roleCounts[$targetRole]) || $roleCounts[$targetRole] <= 0) { return false; } $knownRoleTotal = ($roleCounts[ProductRoleResolver::ROLE_MAIN_DEVICE] ?? 0) + ($roleCounts[ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE] ?? 0); if ($knownRoleTotal <= 0) { return false; } return $roleCounts[$targetRole] < $knownRoleTotal; } /** * @param array{shop_query:string, shop_price_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array} $context */ private function renderFollowUpActionPrompt(string $prompt, array $context): string { $rendered = strtr($prompt, [ '{shop_query}' => $context['shop_query'], '{shop_price_query}' => $context['shop_price_query'], '{answer_anchor}' => $context['answer_anchor'], ]); return $this->normalizeOneLine($rendered); } private function buildShopSearchMetaMessage( string $query, string $commerceIntent, bool $usedOptimizedQuery, string $originalQuery, ?int $resultCount = null, bool $completed = false, bool $attemptedRepair = false, bool $usedRepair = false, bool $unavailable = false ): string { $query = $this->normalizeOneLine($query); $originalQuery = $this->normalizeOneLine($originalQuery); if ($query === '') { $query = $originalQuery !== '' ? $originalQuery : $this->agentRunnerConfig->getProductionUiText('shop_meta_fallback_query'); } $queryModeLabel = $usedOptimizedQuery ? $this->agentRunnerConfig->getProductionUiText('shop_meta_query_mode_optimized') : $this->agentRunnerConfig->getProductionUiText('shop_meta_query_mode_direct'); $intentLabel = $commerceIntent !== '' ? $commerceIntent : $this->agentRunnerConfig->getProductionUiText('shop_meta_default_intent'); $title = $unavailable ? $this->agentRunnerConfig->getProductionUiText('shop_meta_title_unavailable') : ($completed ? $this->agentRunnerConfig->getProductionUiText('shop_meta_title_completed') : $this->agentRunnerConfig->getProductionUiText('shop_meta_title_running')); $statusLabel = $completed ? $this->agentRunnerConfig->getProductionUiText('shop_meta_status_completed') : $this->agentRunnerConfig->getProductionUiText('shop_meta_status_running'); $resultLabel = $unavailable ? $this->agentRunnerConfig->getProductionUiText('shop_meta_result_unavailable') : ($resultCount === null ? $this->agentRunnerConfig->getProductionUiText('shop_meta_result_loading') : $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_meta_result_count'), ['count' => (string) max(0, $resultCount)])); $state = $completed ? 'completed' : 'running'; $resultCountAttribute = $resultCount === null ? '' : (string) max(0, $resultCount); $repairLabel = ''; if ($usedRepair) { $repairLabel = $this->agentRunnerConfig->getProductionUiText('shop_meta_repair_used'); } elseif ($attemptedRepair) { $repairLabel = $this->agentRunnerConfig->getProductionUiText('shop_meta_repair_checked'); } $html = '
' . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
' . '' . htmlspecialchars($resultLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . '' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . '' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($queryModeLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . '' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_intent_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($intentLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; if ($repairLabel !== '') { $html .= '' . htmlspecialchars($repairLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; } $html .= '
' . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
'; return $html; } private function buildShopUnavailableMessage(?string $reason): string { $reason = $this->normalizeOneLine((string) $reason); if ($reason === '') { $reason = $this->agentRunnerConfig->getProductionUiText('shop_unavailable_default_reason'); } if (mb_strlen($reason, 'UTF-8') > 320) { $reason = rtrim(mb_substr($reason, 0, 317, 'UTF-8')) . '...'; } return '
' . '
⚠️
' . '
' . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_unavailable_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_unavailable_text_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . htmlspecialchars($reason, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
' . '
'; } /** * @param array $values */ private function renderAgentTemplate(string $template, array $values): string { foreach ($values as $key => $value) { $template = str_replace('{' . $key . '}', $value, $template); } return $template; } private function normalizeOneLine(string $value): string { $value = trim($value); return preg_replace('/\s+/u', ' ', $value) ?? $value; } private function buildUserErrorMessage(Throwable $e): string { $message = trim($e->getMessage()); if ($message === '') { $message = $e::class; } $safeMessage = htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); if (!$this->debug) { return $this->agentRunnerConfig->getGenericInternalErrorMessage() . $this->renderAgentTemplate( $this->agentRunnerConfig->getTechnicalErrorDetailTemplate(), ['message' => $safeMessage] ); } return $this->agentRunnerConfig->getDebugInternalErrorPrefix() . $safeMessage; } private function badge(string $label): string { return sprintf( $this->agentRunnerConfig->getSourceBadgeHtmlTemplate(), htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ); } private function systemMsg(string $msg, string $type = ''): string { if (!$this->systemMsgOn) { return ''; } return match ($type) { 'answer' => $msg, 'err' => sprintf($this->agentRunnerConfig->getErrorHtmlTemplate(), $msg), 'think' => sprintf($this->agentRunnerConfig->getThinkHtmlTemplate(), $msg), 'info' => sprintf($this->agentRunnerConfig->getInfoHtmlTemplate(), $msg), 'meta' => $msg, 'debug' => sprintf( $this->agentRunnerConfig->getDebugHtmlTemplate(), htmlspecialchars($msg, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ), default => $msg, }; } }