From 8ec105686e93d9b2ed61e16347a02cb6a626f9bf Mon Sep 17 00:00:00 2001 From: team 1 Date: Sun, 3 May 2026 14:27:45 +0200 Subject: [PATCH] patch 20n --- AgentRunner.php | 3242 +++++++++++++++++ AgentRunner.php.before_p20n | 3199 ++++++++++++++++ ...INAL_STANDALONE_SHOP_QUERY_GUARD_README.md | 70 + ...IGINAL_PROMPT_SHOP_HISTORY_GUARD_README.md | 61 + ...ALONE_SHOP_HISTORY_AUTHORITY_FIX_README.md | 75 + ...NISTIC_STANDALONE_SHOP_QUERY_FIX_README.md | 70 + src/Agent/AgentRunner.php | 52 +- 7 files changed, 6765 insertions(+), 4 deletions(-) create mode 100644 AgentRunner.php create mode 100644 AgentRunner.php.before_p20n create mode 100644 RETRIEX_PATCH_20K_FINAL_STANDALONE_SHOP_QUERY_GUARD_README.md create mode 100644 RETRIEX_PATCH_20L_ORIGINAL_PROMPT_SHOP_HISTORY_GUARD_README.md create mode 100644 RETRIEX_PATCH_20M_STANDALONE_SHOP_HISTORY_AUTHORITY_FIX_README.md create mode 100644 RETRIEX_PATCH_20N_DETERMINISTIC_STANDALONE_SHOP_QUERY_FIX_README.md diff --git a/AgentRunner.php b/AgentRunner.php new file mode 100644 index 0000000..aa1ff32 --- /dev/null +++ b/AgentRunner.php @@ -0,0 +1,3242 @@ +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: 'Antwort wird vorbereitet', + ragCount: null, + shopCount: null, + shopCountMode: $this->resolveShopCountModeForMeta( + commerceIntent: $commerceIntent, + shopSearchAttempted: $shopSearchAttempted, + shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, + shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery + ), + sourceLabels: $sources, + confidenceLabel: 'Beleglage wird geprüft' + ), + '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: 'Shop-Routing erkannt', + ragCount: null, + shopCount: null, + shopCountMode: $this->resolveShopCountModeForMeta( + commerceIntent: $commerceIntent, + shopSearchAttempted: $shopSearchAttempted, + shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, + shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery + ), + sourceLabels: $sources, + confidenceLabel: 'Shopdaten werden geprüft' + ), + '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: 'RAG-Wissen wurde durchsucht', + 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: 'Shop-Suche wird vorbereitet', + 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); + $shopQueryHistoryContext = $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), + ]); + } + + if ($this->shouldUseDeterministicStandaloneShopQuery($originalPrompt, $shopQueryHistoryContext)) { + $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 = ''; + } + + 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: 'Mehr Kontext nötig', + ragCount: count($knowledgeChunks), + shopCount: null, + shopCountMode: $this->resolveShopCountModeForMeta( + commerceIntent: $commerceIntent, + shopSearchAttempted: $shopSearchAttempted, + shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, + shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery + ), + sourceLabels: $sources, + confidenceLabel: 'mehr Kontext nötig', + 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: 'Shop wird durchsucht', + 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( + 'Shopdaten konnten nicht geladen werden', + $primaryShopSearchFailureReason + ); + + $repairPayload = [ + 'results' => $primaryShopResults, + 'attemptedRepair' => false, + 'usedRepair' => false, + 'repairQueries' => [], + ]; + } else { + yield $this->systemMsg('Erweiterte Shopsuche wird geprüft…', 'think'); + + $repairPayload = $this->repairShopResults( + prompt: $prompt, + userId: $userId, + commerceIntent: $commerceIntent, + commerceHistoryContext: $shopQueryHistoryContext, + primaryQuery: $shopSearchQuery, + primaryShopResults: $primaryShopResults, + knowledgeChunks: $knowledgeChunks + ); + } + } + + $shopResults = $repairPayload['results']; + $attemptedShopRepair = $repairPayload['attemptedRepair']; + $usedShopRepair = $repairPayload['usedRepair']; + $shopRepairQueries = $repairPayload['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 ? 'Shopdaten nicht verfügbar' : 'Shop-Suche abgeschlossen', + 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: 'Antwort wird generiert', + 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 + ); + + $fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer); + + yield $this->systemMsg( + $this->buildProductionUiMetaMessage( + stageLabel: 'Abgeschlossen', + 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' + ); + + 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: 'Antwort wurde unterbrochen', + ragCount: count($knowledgeChunks), + shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null), + shopCountMode: $this->resolveShopCountModeForMeta( + commerceIntent: $commerceIntent, + shopSearchAttempted: $shopSearchAttempted, + shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, + shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery + ), + sourceLabels: $sources, + confidenceLabel: 'nicht abgeschlossen', + completed: true + ), + 'meta' + ); + yield $this->systemMsg($userErrorMessage, 'err'); + + $historyResponse = $this->buildHistoryResponse('', array_merge( + $historyNotices, + [$this->buildHistoryNotice('Antwort konnte nicht abgeschlossen werden', $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 (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 = strtr($token, [ + 'ä' => 'ae', + 'ö' => 'oe', + 'ü' => 'ue', + 'ß' => 'ss', + ]); + $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 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) !== 1) { + 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) !== 1) { + 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->extractLatestAssistantReferenceAnchors($history); + + if ($previousQuestions === [] && $referenceAnchors === []) { + return $prompt; + } + + $lines = []; + + foreach ($previousQuestions as $question) { + $lines[] = 'Vorherige Nutzerfrage: ' . $question; + } + + if ($referenceAnchors !== []) { + $lines[] = 'Vorherige technische Referenzanker (nur zur Referenzauflösung, keine Faktenquelle): ' + . implode(' ', $referenceAnchors); + } + + $lines[] = 'Aktuelle Folgefrage: ' . $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) !== 1) { + 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); + } + + /** + * Extracts stable reference anchors from the latest assistant answer. + * + * These anchors are only used to resolve follow-up references such as + * "der Wert" or "welcher Indikator". They are not factual evidence for + * the final answer. To avoid propagating wrong earlier answers, only the + * first explicit Testomat model reference and the first explicit °dH value + * are kept. Indicator names, reagent codes, prices, URLs and product + * numbers are intentionally ignored here. + * + * @return string[] + */ + private function extractLatestAssistantReferenceAnchors(string $history): array + { + $turn = $this->extractLatestHistoryTurn($history); + + if ($turn === '') { + return []; + } + + $answer = preg_replace($this->agentRunnerConfig->getFollowUpHistoryQuestionStripPattern(), '', $turn, 1) ?? ''; + $answer = trim($answer); + + if ($answer === '') { + return []; + } + + $anchors = []; + + $model = $this->extractFirstTestomatModelAnchor($answer); + if ($model !== '') { + $anchors[] = $model; + } + + $hardnessValue = $this->extractFirstHardnessValueAnchor($answer); + if ($hardnessValue !== '') { + $anchors[] = $hardnessValue; + } + + return array_values(array_unique($anchors)); + } + + private function extractLatestHistoryTurn(string $history): string + { + $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 !== '' + )); + + if ($turns === []) { + return ''; + } + + return (string) end($turns); + } + + /** + * @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 extractFirstTestomatModelAnchor(string $text): string + { + if (preg_match($this->agentRunnerConfig->getFollowUpReferenceAnchorTestomatModelPattern(), $text, $matches) !== 1) { + return ''; + } + + $value = $this->sanitizeHistoryQuestion(($matches[0] ?? '')); + $value = preg_replace('/\s+/u', ' ', $value) ?? $value; + + return trim(str_replace('®', '', $value)); + } + + private function extractFirstHardnessValueAnchor(string $text): string + { + if (preg_match($this->agentRunnerConfig->getFollowUpReferenceAnchorHardnessValuePattern(), $text, $matches) !== 1) { + return ''; + } + + $value = preg_replace('/\s+/u', ' ', ($matches[0] ?? '')) ?? ''; + + return trim($value); + } + + 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 = str_replace(['-', '/', '_'], ' ', $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('Shop-Suchanfrage wird optimiert…', '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->extractFirstTestomatModelAnchor($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 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->extractFirstTestomatModelAnchor($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); + + if ($guardedQuery !== $shopSearchQuery) { + return $guardedQuery; + } + + return $shopSearchQuery; + } + + 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->extractFirstTestomatModelAnchor($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 ($contextQuery !== '' && !$this->isMetaOnlyShopQuery($contextQuery)) { + return $contextQuery; + } + } + + return ''; + } + + private function buildContextFallbackShopQuery(string $question): string + { + $tokens = $this->tokenizeShopQueryCandidate($question); + + if ($tokens === []) { + return ''; + } + + $filterTerms = []; + + foreach (array_merge( + $this->agentRunnerConfig->getShopQueryMetaOnlyTerms(), + $this->agentRunnerConfig->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 tokenizeShopQueryCandidate(string $value): array + { + $value = mb_strtolower(trim($value), 'UTF-8'); + $value = str_replace(['-', '/', '_'], ' ', $value); + + if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', $value, $matches) !== 1) { + 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->agentRunnerConfig->getShopQueryMetaOnlyTerms() 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 = str_replace(['-', '/', '_'], ' ', $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 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 = str_replace(['{anchor}', '{query}'], [$anchor, $query], $template); + $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 + { + $latest = ''; + + foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentPatterns() as $pattern) { + if (@preg_match_all($pattern, $commerceHistoryContext, $matches, PREG_SET_ORDER) === false) { + continue; + } + + foreach ($matches as $match) { + $candidate = trim((string) ($match[0] ?? '')); + if ($candidate !== '') { + $latest = $candidate; + } + } + } + + return $latest; + } + + 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; + $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; + } + + $fullOutput .= $cleanToken; + + $chunk = $chunker->push($cleanToken); + if ($chunk !== null) { + yield $this->systemMsg($chunk, 'answer'); + } + } + } 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; + } + + $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; + } + + /** + * 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 .= ' Ursache: ' . $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 ['- Es wurden Shop-Treffer übergeben, aber keine lesbaren Produktdaten gefunden.']; + } + + 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 : 'Unbenanntes Shop-Produkt'; + + if ($product->productNumber !== null && trim($product->productNumber) !== '') { + $parts[] = 'Art.-Nr. ' . $this->normalizeOneLine($product->productNumber); + } + + if ($product->manufacturer !== null && trim($product->manufacturer) !== '') { + $parts[] = 'Hersteller: ' . $this->normalizeOneLine($product->manufacturer); + } + + if ($product->price !== null && trim($product->price) !== '') { + $parts[] = 'Preis: ' . $this->normalizeOneLine($product->price); + } + + if ($product->available !== null) { + $parts[] = 'Verfügbar: ' . ($product->available ? 'ja' : 'nein'); + } + + if ($product->url !== null && trim($product->url) !== '') { + $parts[] = 'URL: ' . $this->normalizeOneLine($product->url); + } + + if ($requestedProductRole === 'main_device_or_system' && $productRole === 'accessory_or_consumable') { + $parts[] = 'Hinweis: Zubehör/Verbrauchsartikel; nicht als Messanlage/Gerät bestätigt'; + } + + return sprintf('%d. %s', $index, implode(' | ', $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' => 'fachlich belegt', + 'aggregate_missing' => 'geprüfte Quellen, keine passende Zählinformation', + 'weak' => 'RAG-Näherungstreffer, kein direkter Fachbeleg', + default => 'noch keine belastbaren Treffer', + }; + } + + private function resolveRagEvidenceShopCheckConfidenceLabel(string $knowledgeEvidenceState): string + { + return match ($knowledgeEvidenceState) { + 'direct' => 'fachlich belegt; Shopdaten werden geprüft', + 'aggregate_missing' => 'geprüfte Quellen ohne Zählinformation; Shopdaten werden geprüft', + 'weak' => 'RAG-Näherungstreffer; Shopdaten werden geprüft', + default => 'Shopdaten werden geprüft', + }; + } + + 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->agentRunnerConfig->getRagEvidenceStopTerms() 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 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 = '/(? 'ae', + 'ö' => 'oe', + 'ü' => 'ue', + 'ß' => 'ss', + ]); + $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 = 'Es wurden keine Daten vom LLM empfangen.'; + } + + $parts[] = 'Systemhinweis: ' . $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 = 'Systemhinweis'; + } + + if ($detail === '') { + return 'Systemhinweis: ' . $title . '.'; + } + + if (mb_strlen($detail, 'UTF-8') > 500) { + $detail = rtrim(mb_substr($detail, 0, 497, 'UTF-8')) . '...'; + } + + return 'Systemhinweis: ' . $title . '. Ursache: ' . $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 + ? 'RAG-Treffer: wird geprüft' + : 'RAG-Treffer: ' . max(0, $ragCount); + $shopLabel = match ($shopCountMode) { + 'count' => 'Shop-Treffer: ' . max(0, (int) $shopCount), + 'loading' => 'Shop-Treffer: wird geladen', + 'unavailable' => 'Shop-Treffer: nicht verfügbar', + 'not_resolved' => 'Shop-Treffer: keine Suchquery', + default => 'Shop-Treffer: nicht angefragt', + }; + $statusLabel = $completed ? 'Status: abgeschlossen' : 'Status: läuft'; + $sources = $this->formatProductionUiSourceLabels($sourceLabels); + + $html = '
' + . '
RetrieX-Status
' + . '
' . 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') . '' + . 'Beleglage: ' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . '
'; + + if ($sources !== []) { + $html .= '
Datenbasis
'; + + foreach ($sources as $source) { + $html .= '' . htmlspecialchars($source, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + } + + $html .= '
'; + } else { + $emptySourceLabel = $completed ? 'keine belastbare Datenbasis' : 'wird geprüft'; + $html .= '
Datenbasis
' + . 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 + ? 'geprüfte Quellen ohne Zählinformation; Shopdaten nicht verfügbar' + : 'geprüfte Quellen, keine passende Zählinformation'; + } + + if ($shopSearchHadSystemFailure) { + return $hasKnowledge ? 'fachlich belegt; Shopdaten nicht verfügbar' : 'Shopdaten nicht verfügbar'; + } + + if ($hasKnowledge && $hasShopResults) { + return 'RAG + Shopdaten'; + } + + if (!$hasKnowledge && $hasShopResults) { + return 'nur Shopdaten'; + } + + if ($hasKnowledge && $shopSearchAttempted) { + return 'RAG-Wissen, keine Shop-Treffer'; + } + + if ($hasKnowledge) { + return 'fachlich belegt'; + } + + if ($isCommerceIntent || $shopSearchAttempted) { + return 'keine belastbaren Daten'; + } + + return 'noch keine belastbaren Treffer'; + } + + /** + * @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 = 'Live-Shopdaten'; + } + + if (!in_array($label, $labels, true)) { + $labels[] = $label; + } + } + + return $labels; + } + + /** + * @param ShopProductResult[] $shopResults + */ + private function buildShopProductCardsMessage(array $shopResults, string $query, bool $usedRepair): string + { + $maxCards = 5; + $visibleResults = array_slice($shopResults, 0, $maxCards); + $totalCount = count($shopResults); + $query = $this->normalizeOneLine($query); + $summary = $totalCount . ' Shop-Treffer ausgewertet'; + + if ($totalCount > $maxCards) { + $summary .= ' · Top ' . $maxCards . ' angezeigt'; + } + + if ($usedRepair) { + $summary .= ' · erweiterte Shopsuche genutzt'; + } + + $html = '
' + . '
Shop-Ergebnisse
' + . '
Shop-Ergebnisse
' + . '
' . htmlspecialchars($summary, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + + if ($query !== '') { + $html .= '
Ausgewertete Suchquery' + . 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) ?: 'Unbenanntes Produkt'; + $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 .= '
Artikelnummer
' . htmlspecialchars($productNumber !== '' ? $productNumber : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + $html .= '
Preis
' . htmlspecialchars($price !== '' ? $price : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + $html .= '
Verfügbarkeit
' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + + if ($manufacturer !== '') { + $html .= '
Hersteller
' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + } + + $html .= '
' + . '
Relevanz' + . htmlspecialchars($relevance, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '
' + . '
'; + + return $html; + } + + private function formatProductAvailability(?bool $available): string + { + return match ($available) { + true => 'verfügbar', + false => 'nicht verfügbar', + default => 'Shopstatus nicht übermittelt', + }; + } + + 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 'Gefunden über: ' . implode(', ', array_slice($matchedQueries, 0, 3)); + } + + foreach ($product->highlights as $highlight) { + $highlight = $this->normalizeOneLine($this->plainTextFromHtml((string) $highlight)); + + if ($highlight !== '') { + return 'Passender Shop-Hinweis: ' . mb_substr($highlight, 0, 140, 'UTF-8'); + } + } + + $matchSource = $this->normalizeOneLine((string) $product->matchSource); + + if ($matchSource !== '') { + return 'Trefferquelle: ' . $matchSource; + } + + if ($query !== '') { + return 'Passend zur Suchquery: ' . $query; + } + + return 'Aus den Live-Shopdaten übernommen'; + } + + private function buildFollowUpActionsMessage(bool $isCommerceIntent, bool $hasShopResults, bool $hasKnowledge): string + { + if (!$isCommerceIntent && !$hasShopResults && !$hasKnowledge) { + return ''; + } + + $actions = []; + + if ($isCommerceIntent || $hasShopResults) { + $actions[] = ['Im Shop suchen', 'Suche die aktuelle Produktauswahl im Shop.']; + $actions[] = ['Nur Zubehör anzeigen', 'Zeige aus der aktuellen Produktauswahl nur Zubehör.']; + $actions[] = ['Nur Geräte anzeigen', 'Zeige aus der aktuellen Produktauswahl nur Geräte.']; + $actions[] = ['Preis anzeigen', 'Zeige mir die Preise der aktuell relevanten Produkte.']; + } + + if ($hasKnowledge || $hasShopResults) { + $actions[] = ['Technische Details anzeigen', 'Zeige technische Details zur aktuellen Antwort.']; + } + + if ($actions === []) { + return ''; + } + + $html = '
' + . '
Folgeaktionen
' + . '
Was möchtest du als Nächstes tun?
' + . '
'; + + foreach ($actions as [$label, $actionPrompt]) { + $html .= ''; + } + + $html .= '
'; + + return $html; + } + + 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 : 'keine Suchquery ermittelt'; + } + + $queryModeLabel = $usedOptimizedQuery ? 'optimiert' : 'direkt'; + $intentLabel = $commerceIntent !== '' ? $commerceIntent : 'commerce'; + $title = $unavailable ? 'Shopdaten nicht verfügbar' : ($completed ? 'Shop-Suche abgeschlossen' : 'Shop-Suche wird ausgeführt'); + $statusLabel = $completed ? 'Status: abgeschlossen' : 'Status: läuft'; + $resultLabel = $unavailable + ? 'Shoptreffer: nicht verfügbar' + : ($resultCount === null + ? 'Shoptreffer: wird geladen' + : 'Shoptreffer: ' . max(0, $resultCount)); + $state = $completed ? 'completed' : 'running'; + $resultCountAttribute = $resultCount === null ? '' : (string) max(0, $resultCount); + $repairLabel = ''; + + if ($usedRepair) { + $repairLabel = 'Erweiterte Suche: genutzt'; + } elseif ($attemptedRepair) { + $repairLabel = 'Erweiterte Suche: geprüft'; + } + + $html = '
' + . '
Shop-Suche
' + . '
' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' + . '
' + . '' . htmlspecialchars($resultLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . '' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . 'Query: ' . htmlspecialchars($queryModeLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . 'Intent: ' . htmlspecialchars($intentLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + + if ($repairLabel !== '') { + $html .= '' . htmlspecialchars($repairLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + } + + $html .= '
' + . '
Gesendete Suchquery' + . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '
' + . '
'; + + return $html; + } + + private function buildShopUnavailableMessage(?string $reason): string + { + $reason = $this->normalizeOneLine((string) $reason); + + if ($reason === '') { + $reason = 'Keine Detailmeldung vom Shopware-Server.'; + } + + if (mb_strlen($reason, 'UTF-8') > 320) { + $reason = rtrim(mb_substr($reason, 0, 317, 'UTF-8')) . '...'; + } + + return '
' + . '
⚠️
' + . '
' + . '
Shopdaten konnten nicht geladen werden
' + . '
RetrieX antwortet ohne Live-Shopdaten weiter. Ursache: ' + . htmlspecialchars($reason, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '
' + . '
' + . '
'; + } + + 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() + . '
Technischer Fehler: ' . $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, + }; + } +} diff --git a/AgentRunner.php.before_p20n b/AgentRunner.php.before_p20n new file mode 100644 index 0000000..38f242d --- /dev/null +++ b/AgentRunner.php.before_p20n @@ -0,0 +1,3199 @@ +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: 'Antwort wird vorbereitet', + ragCount: null, + shopCount: null, + shopCountMode: $this->resolveShopCountModeForMeta( + commerceIntent: $commerceIntent, + shopSearchAttempted: $shopSearchAttempted, + shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, + shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery + ), + sourceLabels: $sources, + confidenceLabel: 'Beleglage wird geprüft' + ), + '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: 'Shop-Routing erkannt', + ragCount: null, + shopCount: null, + shopCountMode: $this->resolveShopCountModeForMeta( + commerceIntent: $commerceIntent, + shopSearchAttempted: $shopSearchAttempted, + shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, + shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery + ), + sourceLabels: $sources, + confidenceLabel: 'Shopdaten werden geprüft' + ), + '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: 'RAG-Wissen wurde durchsucht', + 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: 'Shop-Suche wird vorbereitet', + 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); + $shopQueryHistoryContext = $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), + ]); + } + + $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 = ''; + } + + 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: 'Mehr Kontext nötig', + ragCount: count($knowledgeChunks), + shopCount: null, + shopCountMode: $this->resolveShopCountModeForMeta( + commerceIntent: $commerceIntent, + shopSearchAttempted: $shopSearchAttempted, + shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, + shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery + ), + sourceLabels: $sources, + confidenceLabel: 'mehr Kontext nötig', + 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: 'Shop wird durchsucht', + 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( + 'Shopdaten konnten nicht geladen werden', + $primaryShopSearchFailureReason + ); + + $repairPayload = [ + 'results' => $primaryShopResults, + 'attemptedRepair' => false, + 'usedRepair' => false, + 'repairQueries' => [], + ]; + } else { + yield $this->systemMsg('Erweiterte Shopsuche wird geprüft…', 'think'); + + $repairPayload = $this->repairShopResults( + prompt: $prompt, + userId: $userId, + commerceIntent: $commerceIntent, + commerceHistoryContext: $shopQueryHistoryContext, + primaryQuery: $shopSearchQuery, + primaryShopResults: $primaryShopResults, + knowledgeChunks: $knowledgeChunks + ); + } + } + + $shopResults = $repairPayload['results']; + $attemptedShopRepair = $repairPayload['attemptedRepair']; + $usedShopRepair = $repairPayload['usedRepair']; + $shopRepairQueries = $repairPayload['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 ? 'Shopdaten nicht verfügbar' : 'Shop-Suche abgeschlossen', + 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: 'Antwort wird generiert', + 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 + ); + + $fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer); + + yield $this->systemMsg( + $this->buildProductionUiMetaMessage( + stageLabel: 'Abgeschlossen', + 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' + ); + + 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: 'Antwort wurde unterbrochen', + ragCount: count($knowledgeChunks), + shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null), + shopCountMode: $this->resolveShopCountModeForMeta( + commerceIntent: $commerceIntent, + shopSearchAttempted: $shopSearchAttempted, + shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, + shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery + ), + sourceLabels: $sources, + confidenceLabel: 'nicht abgeschlossen', + completed: true + ), + 'meta' + ); + yield $this->systemMsg($userErrorMessage, 'err'); + + $historyResponse = $this->buildHistoryResponse('', array_merge( + $historyNotices, + [$this->buildHistoryNotice('Antwort konnte nicht abgeschlossen werden', $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 (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 = strtr($token, [ + 'ä' => 'ae', + 'ö' => 'oe', + 'ü' => 'ue', + 'ß' => 'ss', + ]); + $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 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) !== 1) { + 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) !== 1) { + 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->extractLatestAssistantReferenceAnchors($history); + + if ($previousQuestions === [] && $referenceAnchors === []) { + return $prompt; + } + + $lines = []; + + foreach ($previousQuestions as $question) { + $lines[] = 'Vorherige Nutzerfrage: ' . $question; + } + + if ($referenceAnchors !== []) { + $lines[] = 'Vorherige technische Referenzanker (nur zur Referenzauflösung, keine Faktenquelle): ' + . implode(' ', $referenceAnchors); + } + + $lines[] = 'Aktuelle Folgefrage: ' . $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) !== 1) { + 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); + } + + /** + * Extracts stable reference anchors from the latest assistant answer. + * + * These anchors are only used to resolve follow-up references such as + * "der Wert" or "welcher Indikator". They are not factual evidence for + * the final answer. To avoid propagating wrong earlier answers, only the + * first explicit Testomat model reference and the first explicit °dH value + * are kept. Indicator names, reagent codes, prices, URLs and product + * numbers are intentionally ignored here. + * + * @return string[] + */ + private function extractLatestAssistantReferenceAnchors(string $history): array + { + $turn = $this->extractLatestHistoryTurn($history); + + if ($turn === '') { + return []; + } + + $answer = preg_replace($this->agentRunnerConfig->getFollowUpHistoryQuestionStripPattern(), '', $turn, 1) ?? ''; + $answer = trim($answer); + + if ($answer === '') { + return []; + } + + $anchors = []; + + $model = $this->extractFirstTestomatModelAnchor($answer); + if ($model !== '') { + $anchors[] = $model; + } + + $hardnessValue = $this->extractFirstHardnessValueAnchor($answer); + if ($hardnessValue !== '') { + $anchors[] = $hardnessValue; + } + + return array_values(array_unique($anchors)); + } + + private function extractLatestHistoryTurn(string $history): string + { + $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 !== '' + )); + + if ($turns === []) { + return ''; + } + + return (string) end($turns); + } + + /** + * @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 extractFirstTestomatModelAnchor(string $text): string + { + if (preg_match($this->agentRunnerConfig->getFollowUpReferenceAnchorTestomatModelPattern(), $text, $matches) !== 1) { + return ''; + } + + $value = $this->sanitizeHistoryQuestion(($matches[0] ?? '')); + $value = preg_replace('/\s+/u', ' ', $value) ?? $value; + + return trim(str_replace('®', '', $value)); + } + + private function extractFirstHardnessValueAnchor(string $text): string + { + if (preg_match($this->agentRunnerConfig->getFollowUpReferenceAnchorHardnessValuePattern(), $text, $matches) !== 1) { + return ''; + } + + $value = preg_replace('/\s+/u', ' ', ($matches[0] ?? '')) ?? ''; + + return trim($value); + } + + 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 = str_replace(['-', '/', '_'], ' ', $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('Shop-Suchanfrage wird optimiert…', '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->extractFirstTestomatModelAnchor($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 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->extractFirstTestomatModelAnchor($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); + + if ($guardedQuery !== $shopSearchQuery) { + return $guardedQuery; + } + + return $shopSearchQuery; + } + + 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->extractFirstTestomatModelAnchor($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 ($contextQuery !== '' && !$this->isMetaOnlyShopQuery($contextQuery)) { + return $contextQuery; + } + } + + return ''; + } + + private function buildContextFallbackShopQuery(string $question): string + { + $tokens = $this->tokenizeShopQueryCandidate($question); + + if ($tokens === []) { + return ''; + } + + $filterTerms = []; + + foreach (array_merge( + $this->agentRunnerConfig->getShopQueryMetaOnlyTerms(), + $this->agentRunnerConfig->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 tokenizeShopQueryCandidate(string $value): array + { + $value = mb_strtolower(trim($value), 'UTF-8'); + $value = str_replace(['-', '/', '_'], ' ', $value); + + if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', $value, $matches) !== 1) { + 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->agentRunnerConfig->getShopQueryMetaOnlyTerms() 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 = str_replace(['-', '/', '_'], ' ', $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 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 = str_replace(['{anchor}', '{query}'], [$anchor, $query], $template); + $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 + { + $latest = ''; + + foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentPatterns() as $pattern) { + if (@preg_match_all($pattern, $commerceHistoryContext, $matches, PREG_SET_ORDER) === false) { + continue; + } + + foreach ($matches as $match) { + $candidate = trim((string) ($match[0] ?? '')); + if ($candidate !== '') { + $latest = $candidate; + } + } + } + + return $latest; + } + + 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; + $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; + } + + $fullOutput .= $cleanToken; + + $chunk = $chunker->push($cleanToken); + if ($chunk !== null) { + yield $this->systemMsg($chunk, 'answer'); + } + } + } 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; + } + + $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; + } + + /** + * 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 .= ' Ursache: ' . $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 ['- Es wurden Shop-Treffer übergeben, aber keine lesbaren Produktdaten gefunden.']; + } + + 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 : 'Unbenanntes Shop-Produkt'; + + if ($product->productNumber !== null && trim($product->productNumber) !== '') { + $parts[] = 'Art.-Nr. ' . $this->normalizeOneLine($product->productNumber); + } + + if ($product->manufacturer !== null && trim($product->manufacturer) !== '') { + $parts[] = 'Hersteller: ' . $this->normalizeOneLine($product->manufacturer); + } + + if ($product->price !== null && trim($product->price) !== '') { + $parts[] = 'Preis: ' . $this->normalizeOneLine($product->price); + } + + if ($product->available !== null) { + $parts[] = 'Verfügbar: ' . ($product->available ? 'ja' : 'nein'); + } + + if ($product->url !== null && trim($product->url) !== '') { + $parts[] = 'URL: ' . $this->normalizeOneLine($product->url); + } + + if ($requestedProductRole === 'main_device_or_system' && $productRole === 'accessory_or_consumable') { + $parts[] = 'Hinweis: Zubehör/Verbrauchsartikel; nicht als Messanlage/Gerät bestätigt'; + } + + return sprintf('%d. %s', $index, implode(' | ', $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' => 'fachlich belegt', + 'aggregate_missing' => 'geprüfte Quellen, keine passende Zählinformation', + 'weak' => 'RAG-Näherungstreffer, kein direkter Fachbeleg', + default => 'noch keine belastbaren Treffer', + }; + } + + private function resolveRagEvidenceShopCheckConfidenceLabel(string $knowledgeEvidenceState): string + { + return match ($knowledgeEvidenceState) { + 'direct' => 'fachlich belegt; Shopdaten werden geprüft', + 'aggregate_missing' => 'geprüfte Quellen ohne Zählinformation; Shopdaten werden geprüft', + 'weak' => 'RAG-Näherungstreffer; Shopdaten werden geprüft', + default => 'Shopdaten werden geprüft', + }; + } + + 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->agentRunnerConfig->getRagEvidenceStopTerms() 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 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 = '/(? 'ae', + 'ö' => 'oe', + 'ü' => 'ue', + 'ß' => 'ss', + ]); + $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 = 'Es wurden keine Daten vom LLM empfangen.'; + } + + $parts[] = 'Systemhinweis: ' . $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 = 'Systemhinweis'; + } + + if ($detail === '') { + return 'Systemhinweis: ' . $title . '.'; + } + + if (mb_strlen($detail, 'UTF-8') > 500) { + $detail = rtrim(mb_substr($detail, 0, 497, 'UTF-8')) . '...'; + } + + return 'Systemhinweis: ' . $title . '. Ursache: ' . $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 + ? 'RAG-Treffer: wird geprüft' + : 'RAG-Treffer: ' . max(0, $ragCount); + $shopLabel = match ($shopCountMode) { + 'count' => 'Shop-Treffer: ' . max(0, (int) $shopCount), + 'loading' => 'Shop-Treffer: wird geladen', + 'unavailable' => 'Shop-Treffer: nicht verfügbar', + 'not_resolved' => 'Shop-Treffer: keine Suchquery', + default => 'Shop-Treffer: nicht angefragt', + }; + $statusLabel = $completed ? 'Status: abgeschlossen' : 'Status: läuft'; + $sources = $this->formatProductionUiSourceLabels($sourceLabels); + + $html = '
' + . '
RetrieX-Status
' + . '
' . 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') . '' + . 'Beleglage: ' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . '
'; + + if ($sources !== []) { + $html .= '
Datenbasis
'; + + foreach ($sources as $source) { + $html .= '' . htmlspecialchars($source, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + } + + $html .= '
'; + } else { + $emptySourceLabel = $completed ? 'keine belastbare Datenbasis' : 'wird geprüft'; + $html .= '
Datenbasis
' + . 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 + ? 'geprüfte Quellen ohne Zählinformation; Shopdaten nicht verfügbar' + : 'geprüfte Quellen, keine passende Zählinformation'; + } + + if ($shopSearchHadSystemFailure) { + return $hasKnowledge ? 'fachlich belegt; Shopdaten nicht verfügbar' : 'Shopdaten nicht verfügbar'; + } + + if ($hasKnowledge && $hasShopResults) { + return 'RAG + Shopdaten'; + } + + if (!$hasKnowledge && $hasShopResults) { + return 'nur Shopdaten'; + } + + if ($hasKnowledge && $shopSearchAttempted) { + return 'RAG-Wissen, keine Shop-Treffer'; + } + + if ($hasKnowledge) { + return 'fachlich belegt'; + } + + if ($isCommerceIntent || $shopSearchAttempted) { + return 'keine belastbaren Daten'; + } + + return 'noch keine belastbaren Treffer'; + } + + /** + * @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 = 'Live-Shopdaten'; + } + + if (!in_array($label, $labels, true)) { + $labels[] = $label; + } + } + + return $labels; + } + + /** + * @param ShopProductResult[] $shopResults + */ + private function buildShopProductCardsMessage(array $shopResults, string $query, bool $usedRepair): string + { + $maxCards = 5; + $visibleResults = array_slice($shopResults, 0, $maxCards); + $totalCount = count($shopResults); + $query = $this->normalizeOneLine($query); + $summary = $totalCount . ' Shop-Treffer ausgewertet'; + + if ($totalCount > $maxCards) { + $summary .= ' · Top ' . $maxCards . ' angezeigt'; + } + + if ($usedRepair) { + $summary .= ' · erweiterte Shopsuche genutzt'; + } + + $html = '
' + . '
Shop-Ergebnisse
' + . '
Shop-Ergebnisse
' + . '
' . htmlspecialchars($summary, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + + if ($query !== '') { + $html .= '
Ausgewertete Suchquery' + . 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) ?: 'Unbenanntes Produkt'; + $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 .= '
Artikelnummer
' . htmlspecialchars($productNumber !== '' ? $productNumber : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + $html .= '
Preis
' . htmlspecialchars($price !== '' ? $price : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + $html .= '
Verfügbarkeit
' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + + if ($manufacturer !== '') { + $html .= '
Hersteller
' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + } + + $html .= '
' + . '
Relevanz' + . htmlspecialchars($relevance, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '
' + . '
'; + + return $html; + } + + private function formatProductAvailability(?bool $available): string + { + return match ($available) { + true => 'verfügbar', + false => 'nicht verfügbar', + default => 'Shopstatus nicht übermittelt', + }; + } + + 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 'Gefunden über: ' . implode(', ', array_slice($matchedQueries, 0, 3)); + } + + foreach ($product->highlights as $highlight) { + $highlight = $this->normalizeOneLine($this->plainTextFromHtml((string) $highlight)); + + if ($highlight !== '') { + return 'Passender Shop-Hinweis: ' . mb_substr($highlight, 0, 140, 'UTF-8'); + } + } + + $matchSource = $this->normalizeOneLine((string) $product->matchSource); + + if ($matchSource !== '') { + return 'Trefferquelle: ' . $matchSource; + } + + if ($query !== '') { + return 'Passend zur Suchquery: ' . $query; + } + + return 'Aus den Live-Shopdaten übernommen'; + } + + private function buildFollowUpActionsMessage(bool $isCommerceIntent, bool $hasShopResults, bool $hasKnowledge): string + { + if (!$isCommerceIntent && !$hasShopResults && !$hasKnowledge) { + return ''; + } + + $actions = []; + + if ($isCommerceIntent || $hasShopResults) { + $actions[] = ['Im Shop suchen', 'Suche die aktuelle Produktauswahl im Shop.']; + $actions[] = ['Nur Zubehör anzeigen', 'Zeige aus der aktuellen Produktauswahl nur Zubehör.']; + $actions[] = ['Nur Geräte anzeigen', 'Zeige aus der aktuellen Produktauswahl nur Geräte.']; + $actions[] = ['Preis anzeigen', 'Zeige mir die Preise der aktuell relevanten Produkte.']; + } + + if ($hasKnowledge || $hasShopResults) { + $actions[] = ['Technische Details anzeigen', 'Zeige technische Details zur aktuellen Antwort.']; + } + + if ($actions === []) { + return ''; + } + + $html = '
' + . '
Folgeaktionen
' + . '
Was möchtest du als Nächstes tun?
' + . '
'; + + foreach ($actions as [$label, $actionPrompt]) { + $html .= ''; + } + + $html .= '
'; + + return $html; + } + + 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 : 'keine Suchquery ermittelt'; + } + + $queryModeLabel = $usedOptimizedQuery ? 'optimiert' : 'direkt'; + $intentLabel = $commerceIntent !== '' ? $commerceIntent : 'commerce'; + $title = $unavailable ? 'Shopdaten nicht verfügbar' : ($completed ? 'Shop-Suche abgeschlossen' : 'Shop-Suche wird ausgeführt'); + $statusLabel = $completed ? 'Status: abgeschlossen' : 'Status: läuft'; + $resultLabel = $unavailable + ? 'Shoptreffer: nicht verfügbar' + : ($resultCount === null + ? 'Shoptreffer: wird geladen' + : 'Shoptreffer: ' . max(0, $resultCount)); + $state = $completed ? 'completed' : 'running'; + $resultCountAttribute = $resultCount === null ? '' : (string) max(0, $resultCount); + $repairLabel = ''; + + if ($usedRepair) { + $repairLabel = 'Erweiterte Suche: genutzt'; + } elseif ($attemptedRepair) { + $repairLabel = 'Erweiterte Suche: geprüft'; + } + + $html = '
' + . '
Shop-Suche
' + . '
' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' + . '
' + . '' . htmlspecialchars($resultLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . '' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . 'Query: ' . htmlspecialchars($queryModeLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . 'Intent: ' . htmlspecialchars($intentLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + + if ($repairLabel !== '') { + $html .= '' . htmlspecialchars($repairLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + } + + $html .= '
' + . '
Gesendete Suchquery' + . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '
' + . '
'; + + return $html; + } + + private function buildShopUnavailableMessage(?string $reason): string + { + $reason = $this->normalizeOneLine((string) $reason); + + if ($reason === '') { + $reason = 'Keine Detailmeldung vom Shopware-Server.'; + } + + if (mb_strlen($reason, 'UTF-8') > 320) { + $reason = rtrim(mb_substr($reason, 0, 317, 'UTF-8')) . '...'; + } + + return '
' + . '
⚠️
' + . '
' + . '
Shopdaten konnten nicht geladen werden
' + . '
RetrieX antwortet ohne Live-Shopdaten weiter. Ursache: ' + . htmlspecialchars($reason, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '
' + . '
' + . '
'; + } + + 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() + . '
Technischer Fehler: ' . $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, + }; + } +} diff --git a/RETRIEX_PATCH_20K_FINAL_STANDALONE_SHOP_QUERY_GUARD_README.md b/RETRIEX_PATCH_20K_FINAL_STANDALONE_SHOP_QUERY_GUARD_README.md new file mode 100644 index 0000000..66e9741 --- /dev/null +++ b/RETRIEX_PATCH_20K_FINAL_STANDALONE_SHOP_QUERY_GUARD_README.md @@ -0,0 +1,70 @@ +# RetrieX Patch p20k - Final Standalone Shop Query Guard + +## Ziel +Behebt den Fall, dass eine neue eigenständige Shop-Anfrage wie: + +```text +zeige mir Anschlusskabel für pH/Redox +``` + +nach der Shop-Query-Optimierung fälschlich als alte Kontext-Query wie: + +```text +testomat 808 indikator +``` + +an Shop-Suche und UI-Meta weitergegeben wird. + +## Ursache +Der bisherige Guard prüfte den Optimizer-Ausgang zwar im Optimizer-/Resolve-Pfad, aber nicht final direkt vor Preview und Shop-Suche. Wenn später dennoch eine kontextfremde Query in `$shopSearchQuery` landete, wurde sie ungeprüft an `buildSearchQueryPreview()` und `searchShop()` weitergereicht. + +## Änderung +Nur `src/Agent/AgentRunner.php` wurde geändert. + +Neu: + +- finale Validierung direkt nach `resolveShopSearchQuery()` +- vor `buildSearchQueryPreview()` und `searchShop()` +- Wiederverwendung der bestehenden Standalone-Guardlogik +- bei erkannter Kontextverschmutzung wird die Query auf den aktuellen Routing-Prompt zurückgesetzt +- `$optimizedShopQuery` wird geleert, damit UI/Meta nicht weiter „optimiert“ für eine verworfene Query anzeigen + +## Erwartung + +```text +zeige mir Anschlusskabel für pH/Redox +``` + +soll nicht mehr als `testomat 808 indikator` gesucht werden, sondern auf Basis des aktuellen Prompts, also sinngemäß: + +```text +anschlusskabel ph redox +``` + +Referenzielle Follow-ups wie: + +```text +die tabelle mit preisen +was kostet der indikator +``` + +dürfen weiterhin Kontext verwenden. + +## Checks +Lokal möglich: + +```bash +php -l src/Agent/AgentRunner.php +``` + +Pflichtchecks nach Einspielen: + +```bash +bin/console cache:clear +bin/console mto:agent:config:validate +bin/console mto:agent:regression:test +bin/console mto:agent:config:audit-source --details +bin/console mto:agent:config:audit-patterns --details +``` + +Falls OPcache/PHP-FPM aktiv ist: PHP-FPM bzw. Container neu laden. diff --git a/RETRIEX_PATCH_20L_ORIGINAL_PROMPT_SHOP_HISTORY_GUARD_README.md b/RETRIEX_PATCH_20L_ORIGINAL_PROMPT_SHOP_HISTORY_GUARD_README.md new file mode 100644 index 0000000..656c5e9 --- /dev/null +++ b/RETRIEX_PATCH_20L_ORIGINAL_PROMPT_SHOP_HISTORY_GUARD_README.md @@ -0,0 +1,61 @@ +# RetrieX Patch p20l - Original Prompt Shop History Guard + +## Ziel +Fix fuer die Regression, bei der eine neue Standalone-Shopfrage wie: + +```text +zeige mir Anschlusskabel fuer pH/Redox +``` + +faelschlich mit altem Verlaufskontext optimiert wurde und dadurch als Shop-Suchquery z. B. `testomat 808 indikator` erzeugte. + +## Ursache +Der bisherige Guard pruefte die finale Shop-Suchquery gegen den `routingPrompt`. Dieser kann durch LLM-/Fuzzy-Normalisierung leicht vom Original abweichen. Dadurch konnte eine eigentlich neue Standalone-Anfrage intern wie eine referenzielle Anfrage behandelt werden, und alter Commerce-Verlauf durfte in Optimizer/Query einfliessen. + +Besonders kritisch: Die Entscheidung, ob Verlauf fuer die Shop-Query benutzt werden darf, darf nicht von einer normalisierten Variante abhaengen, sondern vom originalen Nutzereingabetext. + +## Aenderung +Nur `src/Agent/AgentRunner.php` wurde geaendert. + +1. `resolveShopQueryHistoryContext()` verwendet jetzt den `originalPrompt` als Autoritaet fuer die Entscheidung, ob Commerce-History genutzt werden darf. +2. `guardFinalStandaloneShopSearchQuery()` prueft finale Shop-Suchqueries ebenfalls gegen den `originalPrompt`. +3. Wenn eine Standalone-Query fremden Kontext einfuehrt, faellt RetrieX auf den aktuellen `routingPrompt` zurueck, nicht auf alten Verlaufskontext. + +## Erwartetes Verhalten + +```text +zeige mir Anschlusskabel fuer pH/Redox +``` + +Erwartete Suchquery sinngemaess: + +```text +anschlusskabel ph redox +``` + +Nicht mehr: + +```text +testomat 808 indikator +``` + +Weiterhin erlaubt: + +```text +welche grenzwerte kann der testomat 808 messen +die tabelle mit preisen +``` + +Dieser Follow-up bleibt referenziell und darf den Verlauf fuer `testomat 808 indikator` nutzen. + +## Pflichtchecks nach Einspielen + +```bash +bin/console cache:clear +bin/console mto:agent:config:validate +bin/console mto:agent:regression:test +bin/console mto:agent:config:audit-source --details +bin/console mto:agent:config:audit-patterns --details +``` + +Bei aktivem OPcache/PHP-FPM bitte PHP-FPM bzw. Container neu laden. diff --git a/RETRIEX_PATCH_20M_STANDALONE_SHOP_HISTORY_AUTHORITY_FIX_README.md b/RETRIEX_PATCH_20M_STANDALONE_SHOP_HISTORY_AUTHORITY_FIX_README.md new file mode 100644 index 0000000..e58659a --- /dev/null +++ b/RETRIEX_PATCH_20M_STANDALONE_SHOP_HISTORY_AUTHORITY_FIX_README.md @@ -0,0 +1,75 @@ +# RetrieX Patch p20m – Standalone Shop History Authority Fix + +## Ziel +Dieser Patch behebt den Fall, dass eine neue eigenständige Shop-Anfrage wie: + +```text +zeige mir Anschlusskabel für pH/Redox +``` + +trotz fehlender Ähnlichkeit zum aktuellen Prompt mit altem Verlaufskontext zu einer falschen Shop-Suchquery wie: + +```text +testomat 808 indikator +``` + +optimiert wurde. + +## Ursache +Die Entscheidung, ob Chatverlauf für die Shop-Query-Optimierung verwendet werden darf, wurde im aktiven Codepfad weiterhin anhand des normalisierten/routing-orientierten Prompts getroffen. Dieser Prompt ist für Intent-Erkennung hilfreich, darf aber nicht die Autorität für History-Carry-over sein. + +Zusätzlich konnte der fallback-basierte Query-Resolver nach einer Optimierung nicht konsequent zwischen aktueller Standalone-Anfrage und referenzieller Folgefrage unterscheiden. + +## Änderung +Geändert wurde nur: + +- `src/Agent/AgentRunner.php` + +Konkret: + +1. `resolveShopQueryHistoryContext()` entscheidet jetzt anhand des unveränderten `originalPrompt`, ob Shop-History benutzt werden darf. +2. `resolveShopSearchQuery()` nutzt den `originalPrompt` als Autorität für referenzielle History-Fallbacks. +3. Der normalisierte `routingPrompt` bleibt als Current-Prompt-Fallback erhalten, damit Tippfehlerkorrektur/Normalisierung weiterhin für die aktuelle Suchanfrage genutzt werden kann. +4. Der finale Standalone-Guard prüft gegen den `originalPrompt`, nicht gegen den normalisierten Routing-Prompt. + +## Erwartetes Verhalten + +```text +zeige mir Anschlusskabel für pH/Redox +``` + +muss eine Query wie: + +```text +anschlusskabel ph redox +``` + +erzeugen, nicht mehr: + +```text +testomat 808 indikator +``` + +Referenzielle Follow-ups wie: + +```text +die tabelle mit preisen +``` + +behalten weiterhin History-Nutzung. + +## Prüfungen lokal + +- `php -l src/Agent/AgentRunner.php` OK + +Symfony-Console-Checks müssen in der Zielumgebung ausgeführt werden: + +```bash +bin/console cache:clear +bin/console mto:agent:config:validate +bin/console mto:agent:regression:test +bin/console mto:agent:config:audit-source --details +bin/console mto:agent:config:audit-patterns --details +``` + +Bei OPcache/PHP-FPM bitte PHP-FPM bzw. Container neu laden. diff --git a/RETRIEX_PATCH_20N_DETERMINISTIC_STANDALONE_SHOP_QUERY_FIX_README.md b/RETRIEX_PATCH_20N_DETERMINISTIC_STANDALONE_SHOP_QUERY_FIX_README.md new file mode 100644 index 0000000..eb712b4 --- /dev/null +++ b/RETRIEX_PATCH_20N_DETERMINISTIC_STANDALONE_SHOP_QUERY_FIX_README.md @@ -0,0 +1,70 @@ +# RetrieX Patch 20n - Deterministic Standalone Shop Query Fix + +## Purpose +Fixes the persistent context leak where a new standalone shop query such as: + +`zeige mir Anschlusskabel für pH/Redox` + +was incorrectly sent to the shop as: + +`testomat 808 indikator` + +## Root cause +The issue was not the shop router itself. The route was already commerce/shop. The unsafe part was that standalone shop queries could still pass through the LLM shop-query optimizer. If the optimizer or later query handling revived previous conversation context, an old Testomat/Indikator topic could become the sent Shopware query even though the current prompt had no overlap with it. + +## Fix +For non-referential standalone shop queries with no allowed shop-query history context: + +- Skip the LLM shop-query optimizer. +- Use the normalized/current prompt deterministically as the shop search source. +- Let `ShopSearchService` / `CommerceQueryParser` build the final Store API search text from the current prompt only. +- Keep LLM/history optimization enabled for referential follow-ups such as `die tabelle mit preisen` or `was kostet der indikator`. + +## Changed file + +- `src/Agent/AgentRunner.php` + +## Expected behavior + +### Standalone shop query +Input: + +`zeige mir Anschlusskabel für pH/Redox` + +Expected sent query: + +`anschlusskabel ph redox` or equivalent current-prompt-only query. + +Must not become: + +`testomat 808 indikator` + +### Explicit product query +Input: + +`shop testomat 808` + +Expected sent query: + +`testomat 808` + +### Referential follow-up +Input after a Testomat 808 indicator table: + +`die tabelle mit preisen` + +Expected behavior: + +History is still allowed and may resolve to `testomat 808 indikator`. + +## Checks to run after applying + +```bash +bin/console cache:clear +bin/console mto:agent:config:validate +bin/console mto:agent:regression:test +bin/console mto:agent:config:audit-source --details +bin/console mto:agent:config:audit-patterns --details +``` + +If OPcache/PHP-FPM is active, reload PHP-FPM or the container after applying. diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index f8b3fbe..38f242d 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -214,7 +214,7 @@ final readonly class AgentRunner $commerceHistoryContext = $this->buildCommerceHistoryContext($userId, $requestContextHint); $shopQueryHistoryContext = $this->resolveShopQueryHistoryContext( - prompt: $routingPrompt, + prompt: $originalPrompt, commerceHistoryContext: $commerceHistoryContext ); @@ -227,6 +227,7 @@ final readonly class AgentRunner 'userId' => $userId, 'prompt' => $prompt, 'routingPrompt' => $routingPrompt, + 'originalPrompt' => $originalPrompt, 'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext), ]); } @@ -238,12 +239,32 @@ final readonly class AgentRunner ); $shopSearchQuery = $this->resolveShopSearchQuery( - prompt: $routingPrompt, + prompt: $originalPrompt, optimizedShopQuery: $optimizedShopQuery, commerceHistoryContext: $shopQueryHistoryContext, - userId: $userId + 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 = ''; + } + if ($shopSearchQuery === '') { $this->agentLogger->info('Commerce search skipped because no concrete shop query could be resolved', [ 'userId' => $userId, @@ -1498,6 +1519,23 @@ final readonly class AgentRunner return $prompt; } + private function guardFinalStandaloneShopSearchQuery(string $prompt, string $shopSearchQuery): string + { + $shopSearchQuery = trim($shopSearchQuery); + + if ($shopSearchQuery === '') { + return ''; + } + + $guardedQuery = $this->guardStandaloneOptimizedShopQuery($prompt, $shopSearchQuery); + + if ($guardedQuery !== $shopSearchQuery) { + return $guardedQuery; + } + + return $shopSearchQuery; + } + private function standaloneOptimizedShopQueryIntroducesUnsupportedContext( string $prompt, string $optimizedShopQuery @@ -1535,7 +1573,8 @@ final readonly class AgentRunner string $prompt, string $optimizedShopQuery, string $commerceHistoryContext, - string $userId + string $userId, + string $currentPromptFallback = '' ): string { if ($this->isCommercialTableFollowUpPrompt($prompt)) { foreach ($this->buildCommercialTableFollowUpContextCandidates($commerceHistoryContext, $userId) as $contextCandidate) { @@ -1551,6 +1590,11 @@ final readonly class AgentRunner return $this->guardStandaloneOptimizedShopQuery($prompt, $optimizedShopQuery); } + $currentPromptFallback = trim($currentPromptFallback); + if ($currentPromptFallback !== '' && !$this->isMetaOnlyShopQuery($currentPromptFallback)) { + return $currentPromptFallback; + } + if (!$this->isMetaOnlyShopQuery($prompt)) { return $prompt; }