systemMsgOn = true; } public function run(string $prompt, string $userId, bool $forceFullContext = false, string $requestContextHint = ''): Generator { $prompt = trim($prompt); if ($prompt === '') { yield $this->systemMsg($this->agentRunnerConfig->getEmptyPromptMessage(), 'err'); return; } $shopResults = []; $primaryShopResults = []; $sources = []; $optimizedShopQuery = ''; $shopSearchQuery = ''; $commerceIntent = CommerceIntentLite::NONE; $knowledgeRetrievalPrompt = $prompt; $usedFollowUpRetrievalContext = false; $commerceHistoryContext = ''; $attemptedShopRepair = false; $usedShopRepair = false; $shopRepairQueries = []; $primaryShopSearchHadSystemFailure = false; $historyNotices = []; $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->agentRunnerConfig->getAnalyzeRequestMessage(), 'think'); yield $this->systemMsg($this->agentRunnerConfig->getCheckInternetSourcesMessage(), 'think'); $urlContent = $this->urlAnalyzer->extractContentFromPrompt($prompt); if ($urlContent !== '') { $this->addSource($sources, $this->agentRunnerConfig->getExternalUrlSourceLabel()); } $commerceIntent = $this->detectCommerceIntent($prompt); yield $this->systemMsg($this->agentRunnerConfig->getRetrieveKnowledgeMessage(), 'think'); $knowledgeRetrievalPrompt = $this->buildKnowledgeRetrievalPrompt( prompt: $prompt, userId: $userId, commerceIntent: $commerceIntent ); $usedFollowUpRetrievalContext = $knowledgeRetrievalPrompt !== $prompt; $knowledgeChunks = $this->retriever->retrieve($knowledgeRetrievalPrompt); if ($knowledgeChunks !== []) { $this->addSource($sources, $this->agentRunnerConfig->getRagKnowledgeSourceLabel()); } if ($usedFollowUpRetrievalContext) { $this->agentLogger->info('Knowledge retrieval used follow-up context', [ 'userId' => $userId, 'prompt' => $prompt, 'knowledgeRetrievalPrompt' => $knowledgeRetrievalPrompt, 'commerceIntent' => $commerceIntent, ]); } if ($this->isCommerceIntent($commerceIntent)) { yield $this->systemMsg($this->agentRunnerConfig->getOptimizeSearchMessage(), 'think'); $commerceHistoryContext = $this->buildCommerceHistoryContext($userId, $requestContextHint); if ($commerceHistoryContext !== '') { $this->addSource($sources, $this->agentRunnerConfig->getConversationHistorySourceLabel()); } $optimizedShopQuery = yield from $this->buildOptimizedShopQuery( $prompt, $userId, $commerceHistoryContext ); $shopSearchQuery = $this->resolveShopSearchQuery( prompt: $prompt, optimizedShopQuery: $optimizedShopQuery, commerceHistoryContext: $commerceHistoryContext, userId: $userId ); $usedResolvedOptimizedShopQuery = $optimizedShopQuery !== '' && $shopSearchQuery === $optimizedShopQuery; if ($shopSearchQuery === '') { $this->agentLogger->info('Commerce search skipped because no concrete shop query could be resolved', [ 'userId' => $userId, 'commerceIntent' => $commerceIntent, 'prompt' => $prompt, 'optimizedShopQuery' => $optimizedShopQuery, 'hasCommerceHistoryContext' => $commerceHistoryContext !== '', 'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext), 'hasRequestContextHint' => trim($requestContextHint) !== '', ]); yield $this->systemMsg( $this->agentRunnerConfig->getNoConcreteShopQueryMessage(), 'info' ); return; } else { $shopQueryPreview = $this->shopSearchService->buildSearchQueryPreview( $shopSearchQuery, $commerceIntent, $commerceHistoryContext ); yield $this->systemMsg( $this->buildShopSearchMetaMessage( query: $shopQueryPreview->searchText !== '' ? $shopQueryPreview->searchText : $shopSearchQuery, commerceIntent: $commerceIntent, usedOptimizedQuery: $usedResolvedOptimizedShopQuery, originalQuery: $shopSearchQuery ), 'meta' ); $this->agentLogger->info('Commerce search prepared', [ 'userId' => $userId, 'commerceIntent' => $commerceIntent, 'usedOptimizedShopQuery' => $usedResolvedOptimizedShopQuery, 'optimizedShopQuery' => $optimizedShopQuery, 'shopSearchQuery' => $shopSearchQuery, 'hasCommerceHistoryContext' => $commerceHistoryContext !== '', 'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext), ]); yield $this->systemMsg( sprintf($this->agentRunnerConfig->getFetchSearchDataMessageTemplate(), $commerceIntent), 'think' ); $primaryShopResults = $this->searchShop( $shopSearchQuery, $commerceIntent, $userId, $commerceHistoryContext ); $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' ); $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: $commerceHistoryContext, primaryQuery: $shopSearchQuery, primaryShopResults: $primaryShopResults, knowledgeChunks: $knowledgeChunks ); } } $shopResults = $repairPayload['results']; $attemptedShopRepair = $repairPayload['attemptedRepair']; $usedShopRepair = $repairPayload['usedRepair']; $shopRepairQueries = $repairPayload['repairQueries']; if ($shopResults !== []) { $this->addSource($sources, $this->agentRunnerConfig->getShopSystemSourceLabel()); } if ($attemptedShopRepair) { $this->addSource($sources, $this->agentRunnerConfig->getExtendedShopSearchSourceLabel()); } } 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 ); 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, ]); } if ($this->debug && $this->logContext) { $this->agentLogger->debug('Conversation context snapshot', [ 'userId' => $userId, 'context' => $this->contextService->buildUserContext( $userId, $forceFullContext ), ]); } if ($sources !== []) { yield $this->emitSources( $sources, $this->agentRunnerConfig->getUsedSourcesPrefix() ); } $fullOutput = yield from $this->streamFinalAnswer($finalPrompt); 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, $prompt, $historyResponse ); } $this->agentLogger->info('Agent run finished', [ 'userId' => $userId, 'outputLength' => mb_strlen($fullOutput), 'contextMode' => $forceFullContext ? 'full' : 'recent', 'commerceIntent' => $commerceIntent, 'primaryShopResultsCount' => count($primaryShopResults), 'shopResultsCount' => count($shopResults), 'attemptedShopRepair' => $attemptedShopRepair, 'usedShopRepair' => $usedShopRepair, 'shopRepairQueries' => $shopRepairQueries, '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($userErrorMessage, 'err'); $historyResponse = $this->buildHistoryResponse('', array_merge( $historyNotices, [$this->buildHistoryNotice('Antwort konnte nicht abgeschlossen werden', $e->getMessage())] )); if ($historyResponse !== '') { $this->contextService->appendHistory($userId, $prompt, $historyResponse); } } } private function detectCommerceIntent(string $prompt): string { $commerceMeta = $this->commerceIntentLite->detect($prompt); return (string) ($commerceMeta['intent'] ?? CommerceIntentLite::NONE); } 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 { $patterns = [ '/\bder\s+wert\b/u', '/\bdieser\s+wert\b/u', '/\bdiesen\s+wert\b/u', '/\bdem\s+wert\b/u', '/\bmit\s+welche(?:m|n|r)?\b/u', '/\bwomit\b/u', '/\bdamit\b/u', '/\bdafuer\b/u', '/\bdafür\b/u', '/\bdazu\b/u', '/\bdaraus\b/u', '/\bwelche(?:r|s|m|n)?\s+indikator\b/u', '/\bwelche(?:r|s|m|n)?\s+indikatortyp\b/u', '/\bindikator\s+(?:dafuer|dafür|dazu|hierfuer|hierfür)\b/u', '/\bwelche(?:r|s|m|n)?\s+bereich\b/u', '/\bwelche(?:r|s|m|n)?\s+messbereich\b/u', '/\bwelche(?:r|s|m|n)?\s+grenzwert\b/u', ]; foreach ($patterns as $pattern) { if (preg_match($pattern, $normalized) === 1) { return true; } } return false; } private function containsExplicitCommercialFollowUpSignal(string $normalized): bool { $commercialSignals = [ 'shop', 'preis', 'preise', 'kostet', 'kosten', 'kaufen', 'bestellen', 'warenkorb', 'lieferzeit', 'verfuegbar', 'verfügbar', 'lager', 'url', 'link', 'artikelnummer', 'sku', 'produktnummer', ]; foreach ($commercialSignals as $signal) { if (str_contains($normalized, $signal)) { 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('/^Question:\s*(.+)$/mi', $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('/^Question:\s*.*(?:\R|$)/u', '', $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('/(?=^Question:\s)/m', $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); } private function extractFirstTestomatModelAnchor(string $text): string { $pattern = '/\bTestomat(?:®)?\s+' . '(?:\d{3,4}|EVO(?:\s+[A-Z]{2,6})?|ECO(?:[-\s]?(?:PLUS|C))?|DUO(?:\s+\d{3,4})?|LAB(?:\s+[A-Z]{2,6})?)' . '\b/iu'; if (preg_match($pattern, $text, $matches) !== 1) { return ''; } $value = $this->sanitizeHistoryQuestion((string) ($matches[0] ?? '')); $value = preg_replace('/\s+/u', ' ', $value) ?? $value; return trim(str_replace('®', '', $value)); } private function extractFirstHardnessValueAnchor(string $text): string { if (preg_match('/\b\d+(?:[,.]\d+)?\s*°\s*dH\b/iu', $text, $matches) !== 1) { return ''; } $value = preg_replace('/\s+/u', ' ', (string) ($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 resolveShopSearchQuery( string $prompt, string $optimizedShopQuery, string $commerceHistoryContext, string $userId ): string { $promptIsMetaOnly = $this->isMetaOnlyShopQuery($prompt); /** * A pure meta command such as "suche im shop" has no own product * semantics. In that case the LLM optimizer must not be trusted as the * primary source because it can copy instruction terms from the query * prompt itself (for example "Shopware 6"). Resolve meta commands * deterministically from the recent conversation instead. */ if (!$promptIsMetaOnly && $optimizedShopQuery !== '' && !$this->isMetaOnlyShopQuery($optimizedShopQuery)) { return $optimizedShopQuery; } if (!$promptIsMetaOnly) { 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 ''; } 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): Generator { $fullOutput = ''; $thinkingNoticeShown = false; $chunker = new StreamChunker(); $this->thinkSuppressor->reset(); yield $this->systemMsg($this->agentRunnerConfig->getThinkingWhileStreamingMessage(), 'think'); $thinkingNoticeShown = true; 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'); } } $finalChunk = $chunker->flush(); if ($finalChunk !== null) { yield $this->systemMsg($finalChunk, 'answer'); } elseif ($fullOutput === '') { yield $this->systemMsg($this->agentRunnerConfig->getNoLlmDataReceivedMessage(), 'err'); } return $fullOutput; } /** * @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 buildShopSearchMetaMessage( string $query, string $commerceIntent, bool $usedOptimizedQuery, string $originalQuery ): string { $query = $this->normalizeOneLine($query); $originalQuery = $this->normalizeOneLine($originalQuery); if ($query === '') { $query = $originalQuery !== '' ? $originalQuery : 'keine Suchquery ermittelt'; } $badge = $usedOptimizedQuery ? 'optimiert' : 'direkt'; $intentLabel = $commerceIntent !== '' ? $commerceIntent : 'commerce'; return '
' . '
Live-Shopdaten
' . '
Shop-Suche wird ausgeführt
' . '
' . '' . htmlspecialchars($badge, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . 'Intent: ' . htmlspecialchars($intentLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . '
' . '
Gesendete Suchquery' . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
'; } 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, }; } }