diff --git a/AgentRunner.php b/AgentRunner.php deleted file mode 100644 index aa1ff32..0000000 --- a/AgentRunner.php +++ /dev/null @@ -1,3242 +0,0 @@ -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 deleted file mode 100644 index 38f242d..0000000 --- a/AgentRunner.php.before_p20n +++ /dev/null @@ -1,3199 +0,0 @@ -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_20O_STANDALONE_SHOP_QUERY_ISOLATION_FIX_README.md b/RETRIEX_PATCH_20O_STANDALONE_SHOP_QUERY_ISOLATION_FIX_README.md new file mode 100644 index 0000000..54d2ab4 --- /dev/null +++ b/RETRIEX_PATCH_20O_STANDALONE_SHOP_QUERY_ISOLATION_FIX_README.md @@ -0,0 +1,75 @@ +# RetrieX Patch p20o – Standalone Shop Query Isolation Fix + +## Ziel + +Dieser Patch korrigiert die Fehlleitung neuer Standalone-Shopfragen wie: + +```text +zeige mir Anschlusskabel für pH/Redox +``` + +Diese Anfrage durfte nicht aus altem Chatverlauf zu einer Shop-Suchquery wie `testomat 808 indikator` werden. + +## Ursache + +Der bisherige Schutz war zu indirekt. Sobald der Shop-Query-Pfad einen Verlaufskontext zuließ, konnte der LLM-Shop-Query-Optimizer oder der nachgelagerte Commerce-Parser ältere Kontextanker wieder in eine neue Suchfrage übernehmen. + +Zusätzlich enthielt der Shop-Optimizer-Prompt konkrete Beispiele wie `Indikator 300`, `Testomat 808` und `Testomat 2000`. Diese Beispiele konnten den Optimizer in genau die falsche Richtung biasen. + +Außerdem konnte der Input-Normalizer den Platzhaltertext `normalized user input` wörtlich zurückgeben. Dieser Patch schützt dagegen ebenfalls. + +## Änderungen + +### `src/Agent/AgentRunner.php` + +- Neue Standalone-Shopfragen werden jetzt explizit von History/Optimizer-Kontext isoliert. +- Die Entscheidung wird aus dem Originalprompt getroffen, nicht aus dem normalisierten Routing-Prompt. +- Nicht-referenzielle Shopfragen verwenden deterministische Query-Erzeugung über den aktuellen Prompt. +- Referenzielle Follow-ups wie `die tabelle mit preisen`, `suche im shop` oder `was kostet der indikator` dürfen weiterhin Verlauf nutzen. +- Placeholder-Ausgaben des Normalizers wie `normalized user input` werden verworfen und auf den Originalprompt zurückgesetzt. + +### `config/retriex/agent.yaml` + +- Der Normalizer-Prompt enthält keinen ``-Platzhalter mehr. +- Die Normalizer-Regel verbietet, den Platzhaltertext wörtlich auszugeben. +- Konkrete Shop-Optimizer-Beispiele `Indikator 300`, `Testomat 808`, `Testomat 2000` wurden aus dem Prompt entfernt. + +## Erwartete manuelle Tests + +```text +zeige mir Anschlusskabel für pH/Redox +``` + +Erwartung: Suchquery sinngemäß `anschlusskabel ph redox`, nicht `testomat 808 indikator`. + +```text +shop testomat 808 +``` + +Erwartung: Suchquery `testomat 808`, nicht `testomat 808 indikator`. + +```text +welche grenzwerte kann der testomat 808 messen + +die tabelle mit preisen +``` + +Erwartung: referenzieller Follow-up darf weiter Verlauf nutzen, sinngemäß `testomat 808 indikator`. + +```text +was kpstet der indikator +``` + +Erwartung: Tippfehler-Normalisierung/Fuzzy-Routing bleibt aktiv und darf den vorherigen Indikator-Kontext nutzen. + +## Pflichtchecks + +```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/config/retriex/agent.yaml b/config/retriex/agent.yaml index ee81b84..cd44c58 100644 --- a/config/retriex/agent.yaml +++ b/config/retriex/agent.yaml @@ -24,10 +24,10 @@ parameters: intro: 'Normalize the following user input for RetrieX routing before intent detection.' output_format_block: |- Output format: - + Return exactly one line: the corrected user input. current_user_input_label: 'USER INPUT' rules: - - '- Output only the normalized user input.' + - '- Output only the corrected user input text itself, never the words "normalized user input".' - '- Correct only obvious typing mistakes, transposed letters, missing umlauts, spacing, and punctuation that clearly preserve the same meaning.' - '- Do not add product names, model numbers, article numbers, measurement values, parameters, brands, or application areas that are not already present in the input.' - '- Preserve product names, model numbers, article numbers, chemical symbols, units, pH, Redox, ORP, and measurement values exactly unless only letter casing is corrected.' @@ -410,7 +410,7 @@ parameters: - '- Preserve the language of the CURRENT USER INPUT for generic product/search terms; do not translate German search terms into English.' - '- For German user input, output German shop terms, for example "freies Chlor Messung" instead of "free chlorine measurement".' - '- Preserve domain terms from the current user input or resolved context in their original language.' - - '- Numbers that belong to a product name or model must be preserved (e.g. Indikator 300, Testomat 808, Testomat 2000).' + - '- Numbers that belong to a product name or model must be preserved when they are present in the CURRENT USER INPUT or a clearly resolved reference.' - '- Separate terms using spaces only.' - '- If a relevant product name is present, it must be placed at the beginning of the final search query.' - '- Try to always identify all products mentioned in the user input text, even in long prompts.' diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index 38f242d..ed70bc0 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -213,10 +213,13 @@ final readonly class AgentRunner yield $this->systemMsg($this->agentRunnerConfig->getOptimizeSearchMessage(), 'think'); $commerceHistoryContext = $this->buildCommerceHistoryContext($userId, $requestContextHint); - $shopQueryHistoryContext = $this->resolveShopQueryHistoryContext( - prompt: $originalPrompt, - commerceHistoryContext: $commerceHistoryContext - ); + $isStandaloneShopQuery = $this->shouldIsolateStandaloneShopQueryFromHistory($originalPrompt); + $shopQueryHistoryContext = $isStandaloneShopQuery + ? '' + : $this->resolveShopQueryHistoryContext( + prompt: $originalPrompt, + commerceHistoryContext: $commerceHistoryContext + ); if ($shopQueryHistoryContext !== '') { $this->addSource($sources, $this->agentRunnerConfig->getConversationHistorySourceLabel()); @@ -229,22 +232,43 @@ final readonly class AgentRunner 'routingPrompt' => $routingPrompt, 'originalPrompt' => $originalPrompt, 'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext), + 'standaloneShopQueryIsolated' => $isStandaloneShopQuery, ]); } - $optimizedShopQuery = yield from $this->buildOptimizedShopQuery( - $routingPrompt, - $userId, - $shopQueryHistoryContext - ); + if ($isStandaloneShopQuery) { + $optimizedShopQuery = ''; + $shopSearchQuery = $this->guardFinalStandaloneShopSearchQuery( + prompt: $originalPrompt, + shopSearchQuery: $routingPrompt + ); - $shopSearchQuery = $this->resolveShopSearchQuery( - prompt: $originalPrompt, - optimizedShopQuery: $optimizedShopQuery, - commerceHistoryContext: $shopQueryHistoryContext, - userId: $userId, - currentPromptFallback: $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, @@ -756,6 +780,10 @@ final readonly class AgentRunner return $originalPrompt; } + if ($this->isInputNormalizationPlaceholderOutput($candidate)) { + return $originalPrompt; + } + if (mb_strlen($candidate, 'UTF-8') > $this->agentRunnerConfig->getInputNormalizationMaxOutputChars()) { return $originalPrompt; } @@ -994,6 +1022,19 @@ final readonly class AgentRunner return true; } + private function isInputNormalizationPlaceholderOutput(string $candidate): bool + { + $normalized = $this->normalizeRoutingComparisonText($candidate); + + return in_array($normalized, [ + 'normalized user input', + 'corrected user input', + 'user input', + 'normalisierte nutzereingabe', + 'korrigierte nutzereingabe', + ], true); + } + private function normalizeRoutingComparisonText(string $value): string { $value = mb_strtolower(trim($value), 'UTF-8'); @@ -1484,6 +1525,74 @@ final readonly class AgentRunner return false; } + private function shouldIsolateStandaloneShopQueryFromHistory(string $prompt): bool + { + $prompt = trim($prompt); + + if ($prompt === '') { + return false; + } + + if ($this->isCommercialTableFollowUpPrompt($prompt) || $this->isMetaOnlyShopQuery($prompt)) { + return false; + } + + $normalizedPrompt = $this->normalizeFollowUpText($prompt); + $usesReferenceLanguage = $this->containsReferentialShopQueryMarker($normalizedPrompt) + || $this->containsConfiguredShopQueryAnchorTrigger($normalizedPrompt); + + if (!$usesReferenceLanguage) { + return true; + } + + return $this->hasStandaloneConcreteShopSubject($prompt); + } + + private function hasStandaloneConcreteShopSubject(string $prompt): bool + { + if ($this->extractFirstTestomatModelAnchor($prompt) !== '') { + return true; + } + + $contextFallbackQuery = $this->buildContextFallbackShopQuery($prompt); + $tokens = $this->tokenizeShopQueryCandidate($contextFallbackQuery); + + if (count($tokens) >= 2) { + return true; + } + + foreach ($tokens as $token) { + if (preg_match('/\d/u', $token) === 1) { + return true; + } + } + + return false; + } + + private function shouldUseDeterministicStandaloneShopQuery(string $prompt, string $shopQueryHistoryContext): bool + { + $prompt = trim($prompt); + + if ($prompt === '') { + return false; + } + + if (trim($shopQueryHistoryContext) !== '') { + return false; + } + + if ($this->isCommercialTableFollowUpPrompt($prompt)) { + return false; + } + + if ($this->isMetaOnlyShopQuery($prompt)) { + return false; + } + + return true; + } + private function guardStandaloneOptimizedShopQuery(string $prompt, string $optimizedShopQuery): string { if ($this->shouldUseCommerceHistoryForShopQuery($prompt)) {