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