diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index 109a2cf..6b06913 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -20,37 +20,39 @@ final readonly class AgentRunner private bool $systemMsgOn; public function __construct( - private PromptBuilder $promptBuilder, - private ThinkSuppressor $thinkSuppressor, - private ContextService $contextService, - private UrlAnalyzer $urlAnalyzer, + private PromptBuilder $promptBuilder, + private ThinkSuppressor $thinkSuppressor, + private ContextService $contextService, + private UrlAnalyzer $urlAnalyzer, private RetrieverInterface $retriever, - private ShopSearchService $shopSearchService, + private ShopSearchService $shopSearchService, private CommerceIntentLite $commerceIntentLite, - private OllamaClient $ollamaClient, - private LoggerInterface $agentLogger, - private AgentRunnerConfig $agentRunnerConfig, - private bool $debug, - private bool $logPrompt, - private bool $logContext, - ) - { + private OllamaClient $ollamaClient, + private LoggerInterface $agentLogger, + private AgentRunnerConfig $agentRunnerConfig, + private bool $debug, + private bool $logPrompt, + private bool $logContext, + ) { $this->systemMsgOn = true; } - public function run(string $prompt, string $userId, ?bool $includeFullContext = false): Generator + public function run(string $prompt, string $userId, bool $includeFullContext = false): Generator { $prompt = trim($prompt); - $swagFullOutPut = ''; - $firstThinkLoop = true; - $shopResults = []; - $sources = []; if ($prompt === '') { - yield '❌ Empty prompt.'; + yield $this->systemMsg('❌ Empty prompt.', 'err'); return; } + $urlContent = ''; + $knowledgeChunks = []; + $shopResults = []; + $sources = []; + $optimizedShopQuery = ''; + $commerceIntent = CommerceIntentLite::NONE; + $this->agentLogger->info('Agent run started', [ 'userId' => $userId, ]); @@ -59,95 +61,63 @@ final readonly class AgentRunner // --------------------------------------------------------- // 1) Context strategy // --------------------------------------------------------- - if ($includeFullContext) { - //Coming soon + // Full context mode is already passed to PromptBuilder. + // Additional context strategies can be added here later. } - yield $this->systemMsg("Ich analysiere deine Anfrage...", "think"); + yield $this->systemMsg('Ich analysiere deine Anfrage...', 'think'); // --------------------------------------------------------- - // 2) Extract URL content (if present) + // 2) Extract URL content // --------------------------------------------------------- - yield $this->systemMsg("Ich prüfe auf Internet Quellen...", "think"); + yield $this->systemMsg('Ich prüfe auf Internetquellen...', 'think'); + $urlContent = $this->urlAnalyzer->extractContentFromPrompt($prompt); - if($urlContent){ - $sources[]= 'Externe URL'; + if ($urlContent !== '') { + $this->addSource($sources, 'Externe URL'); } // --------------------------------------------------------- // 3) Retrieve RAG knowledge // --------------------------------------------------------- - yield $this->systemMsg("Ich hole relevante Daten aus meinem RAG Wissen...", "think"); + yield $this->systemMsg('Ich hole relevante Daten aus meinem RAG-Wissen...', 'think'); + $knowledgeChunks = $this->retriever->retrieve($prompt); - if($knowledgeChunks){ - $sources[]= 'RAG Wissen'; + if ($knowledgeChunks !== []) { + $this->addSource($sources, 'RAG Wissen'); } + // --------------------------------------------------------- - // 4) commerce/shop search + // 4) Optional commerce/shop search // --------------------------------------------------------- + $commerceIntent = $this->detectCommerceIntent($prompt); - $commerceMeta = $this->commerceIntentLite->detect($prompt); - $commerceIntent = (string)($commerceMeta['intent'] ?? CommerceIntentLite::NONE); + if ($this->isCommerceIntent($commerceIntent)) { + yield $this->systemMsg('Ich optimiere die Recherche...', 'think'); - if ($commerceIntent === CommerceIntentLite::PRODUCT_SEARCH || $commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH) { - //PreOptimize swag search query - $promptSwagSearch = $this->agentRunnerConfig->getShopPrompt($prompt); + $optimizedShopQuery = $this->buildOptimizedShopQuery($prompt, $userId); + $shopSearchQuery = $optimizedShopQuery !== '' ? $optimizedShopQuery : $prompt; - //Reset thinkSuppressor - $this->thinkSuppressor->reset(); + yield $this->systemMsg( + 'Ich rufe Recherchedaten ab (type: ' . $commerceIntent . ')', + 'think' + ); - yield $this->systemMsg("Ich optimere die Recherche...", "think"); + $shopResults = $this->searchShop($shopSearchQuery, $commerceIntent, $userId); - //Call AI for optimized swag query - foreach ($this->ollamaClient->stream($promptSwagSearch) as $swagToken) { - - if (!is_string($swagToken)) { - continue; - } - - $swagCleanToken = $this->thinkSuppressor->filter($swagToken); - - if ($swagCleanToken === '') { - continue; - } - - $swagFullOutPut .= $swagCleanToken; - } - - yield $this->systemMsg("Ich rufe Recherchedaten ab (type: " . $commerceIntent . ")", "think"); - - //Search in swag by AI optimized query - try { - $shopResults = $swagFullOutPut !== '' - ? $this->shopSearchService->search($swagFullOutPut, $commerceIntent) - : []; - } catch (Throwable $e) { - $this->agentLogger->warning('Shop search failed, continuing without shop results', [ - 'userId' => $userId, - 'exception' => $e, - ]); - - yield $this->systemMsg('Shopdaten konnten nicht geladen werden, ich antworte mit Wissensbasis weiter...', 'think'); + if ($shopResults !== []) { + $this->addSource($sources, 'Shopsystem'); } } - if($shopResults){ - $sources[]= 'Shopsystem'; - } + $knowledgeChunks = $this->limitKnowledgeChunks($knowledgeChunks, $commerceIntent); - if ($commerceIntent === CommerceIntentLite::PRODUCT_SEARCH) { - $knowledgeChunks = array_slice($knowledgeChunks, 0, 2); - } elseif ($commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH) { - $knowledgeChunks = array_slice($knowledgeChunks, 0, 3); - } - - yield $this->systemMsg("Ich analysiere alle Informationen...", "think"); + yield $this->systemMsg('Ich analysiere alle Informationen...', 'think'); // --------------------------------------------------------- // 5) Build final prompt // --------------------------------------------------------- - $finalPrompt = $this->promptBuilder->build( prompt: $prompt, userId: $userId, @@ -155,15 +125,20 @@ final readonly class AgentRunner knowledgeChunks: $knowledgeChunks, shopResults: $shopResults, fullContext: $includeFullContext, - swagFullOutPut: $swagFullOutPut + swagFullOutPut: $optimizedShopQuery ); if ($this->debug && $this->logPrompt) { - $this->agentLogger->debug($finalPrompt); + $this->agentLogger->debug('Final prompt', [ + 'userId' => $userId, + 'finalPrompt' => $finalPrompt, + 'optimizedShopQuery' => $optimizedShopQuery, + ]); } if ($this->debug && $this->logContext) { $this->agentLogger->debug('Conversation context snapshot', [ + 'userId' => $userId, 'context' => $this->contextService->buildUserContext( $userId, $includeFullContext @@ -171,71 +146,39 @@ final readonly class AgentRunner ]); } + if ($sources !== []) { + yield $this->emitSources($sources, 'Genutzte Quellen: '); + } +print'
';print_r($finalPrompt);exit;
             // ---------------------------------------------------------
-            // 6) Stream tokens from the LLM backend (chunked streaming)
+            // 6) Stream final LLM answer
             // ---------------------------------------------------------
-            $fullOutput = '';
-            $chunker = new StreamChunker();
-            $chunker->flush();
-            $this->thinkSuppressor->reset();
+            $fullOutput = yield from $this->streamFinalAnswer($finalPrompt);
 
-            if($sources){
-                yield $this->systemMsg("Genutze Quellen: ".implode(' ',$sources), 'info');
-            }
-
-            foreach ($this->ollamaClient->stream($finalPrompt) as $token) {
-
-                if (!is_string($token)) {
-                    continue;
-                }
-
-                $cleanToken = $this->thinkSuppressor->filter((string)$token);
-
-                if ($cleanToken === '') {
-                    if ($firstThinkLoop) {
-                        yield $this->systemMsg("Denke nach...", "think");
-                        $firstThinkLoop = false;
-                    }
-                    continue;
-                }
-
-                // Vollständige Antwort weiter sammeln (für History)
-                $fullOutput .= $cleanToken;
-
-                // ⬇️ Token in Chunker geben
-                $chunk = $chunker->push($cleanToken);
-                if ($chunk !== null) {
-                    yield $this->systemMsg($chunk, 'answer');
-                }
-            }
-
-            // ⬇️ Rest flushen
-            $finalChunk = $chunker->flush();
-            if ($finalChunk !== null) {
-                yield $this->systemMsg($finalChunk, 'answer');
-            } elseif ($fullOutput === '') {
-                yield $this->systemMsg('❌ Es wurden keine Daten vom LLM empfangen.', 'err');
-            }
-
-            if($sources){
-                yield $this->systemMsg("Quellen: ".implode(' ',$sources), 'info');
+            if ($sources !== []) {
+                yield $this->emitSources($sources, 'Quellen: ');
             }
 
             // ---------------------------------------------------------
             // 7) Persist conversation history
             // ---------------------------------------------------------
-            $this->contextService->appendHistory(
-                $userId,
-                $prompt,
-                $fullOutput
-            );
+            if ($fullOutput !== '') {
+                $this->contextService->appendHistory(
+                    $userId,
+                    $prompt,
+                    $fullOutput
+                );
+            }
 
             $this->agentLogger->info('Agent run finished', [
                 'userId' => $userId,
                 'outputLength' => mb_strlen($fullOutput),
-                'contextMode' => 'recent',
+                'contextMode' => $includeFullContext ? 'full' : 'recent',
                 'commerceIntent' => $commerceIntent,
                 'shopResultsCount' => count($shopResults),
+                'knowledgeChunkCount' => count($knowledgeChunks),
+                'hasUrlContent' => $urlContent !== '',
+                'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
             ]);
         } catch (Throwable $e) {
             $this->agentLogger->error('Agent run failed', [
@@ -243,10 +186,168 @@ final readonly class AgentRunner
                 'exception' => $e,
             ]);
 
-            yield $this->systemMsg("\n❌ An internal error occurred while processing the request. \nError: " . $e->getMessage(), 'err');
+            yield $this->systemMsg($this->buildUserErrorMessage($e), 'err');
         }
     }
 
+    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 buildOptimizedShopQuery(string $prompt, string $userId): string
+    {
+        $shopPrompt = trim($this->agentRunnerConfig->getShopPrompt($prompt));
+
+        if ($shopPrompt === '') {
+            return '';
+        }
+
+        $optimizedQuery = '';
+        $this->thinkSuppressor->reset();
+
+        try {
+            foreach ($this->ollamaClient->stream($shopPrompt) as $token) {
+                if (!is_string($token)) {
+                    continue;
+                }
+
+                $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 trim($optimizedQuery);
+    }
+
+    private function searchShop(string $query, string $commerceIntent, string $userId): array
+    {
+        try {
+            return $this->shopSearchService->search($query, $commerceIntent);
+        } catch (Throwable $e) {
+            $this->agentLogger->warning('Shop search failed, continuing without shop results', [
+                'userId' => $userId,
+                'commerceIntent' => $commerceIntent,
+                'query' => $query,
+                'exception' => $e,
+            ]);
+
+            return [];
+        }
+    }
+
+    private function limitKnowledgeChunks(array $knowledgeChunks, string $commerceIntent): array
+    {
+        return match ($commerceIntent) {
+            CommerceIntentLite::PRODUCT_SEARCH => array_slice($knowledgeChunks, 0, 2),
+            CommerceIntentLite::ADVISORY_PRODUCT_SEARCH => array_slice($knowledgeChunks, 0, 3),
+            default => $knowledgeChunks,
+        };
+    }
+
+    /**
+     * @return Generator
+     */
+    private function streamFinalAnswer(string $finalPrompt): Generator
+    {
+        $fullOutput = '';
+        $firstThinkLoop = true;
+        $chunker = new StreamChunker();
+
+        $this->thinkSuppressor->reset();
+
+        foreach ($this->ollamaClient->stream($finalPrompt) as $token) {
+            if (!is_string($token)) {
+                continue;
+            }
+
+            $cleanToken = $this->thinkSuppressor->filter($token);
+
+            if ($cleanToken === '') {
+                if ($firstThinkLoop) {
+                    yield $this->systemMsg('Denke nach...', 'think');
+                    $firstThinkLoop = false;
+                }
+
+                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('❌ Es wurden keine Daten vom LLM empfangen.', '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;
+        }
+    }
+
+    private function buildUserErrorMessage(Throwable $e): string
+    {
+        if (!$this->debug) {
+            return '❌ Bei der Verarbeitung der Anfrage ist ein interner Fehler aufgetreten.';
+        }
+
+        return '❌ Interner Fehler: '
+            . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+    }
+
+    private function badge(string $label): string
+    {
+        return sprintf(
+            '%s',
+            htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
+        );
+    }
+
     private function systemMsg(string $msg, string $type = ''): string
     {
         if (!$this->systemMsgOn) {
@@ -254,10 +355,11 @@ final readonly class AgentRunner
         }
 
         return match ($type) {
-            'answer' => '' . $msg,
+            'answer' => $msg,
             'err' => '' . $msg . "\n
\n", 'think' => '' . $msg . "\n", - 'info' => "\n\n" . $msg . "\n" + 'info' => "\n\n" . $msg . "\n", + default => $msg, }; } } \ No newline at end of file diff --git a/src/Config/AgentRunnerConfig.php b/src/Config/AgentRunnerConfig.php index 62bb278..b09ee98 100644 --- a/src/Config/AgentRunnerConfig.php +++ b/src/Config/AgentRunnerConfig.php @@ -22,6 +22,7 @@ class AgentRunnerConfig - Preserve product names, brands, model numbers, and compound terms exactly if they are relevant. - Numbers that belong to a product name or model must be preserved (e.g. Indikator 300, Testomat 808, Testomat 2000). - Separate terms using spaces only. + - If a relevant product name is present, it must be placed at the beginning of the final search query. Output format: Keyword1 Keyword2 Keyword3