244 lines
9.0 KiB
PHP
244 lines
9.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Agent;
|
|
|
|
use App\Commerce\ShopSearchService;
|
|
use App\Config\AgentRunnerConfig;
|
|
use App\Context\ContextService;
|
|
use App\Context\UrlAnalyzer;
|
|
use App\Infrastructure\OllamaClient;
|
|
use App\Intent\CommerceIntentLite;
|
|
use App\Knowledge\Retrieval\RetrieverInterface;
|
|
use Generator;
|
|
use Psr\Log\LoggerInterface;
|
|
use Throwable;
|
|
|
|
final readonly class AgentRunner
|
|
{
|
|
private bool $systemMsgOn;
|
|
|
|
public function __construct(
|
|
private PromptBuilder $promptBuilder,
|
|
private ThinkSuppressor $thinkSuppressor,
|
|
private ContextService $contextService,
|
|
private UrlAnalyzer $urlAnalyzer,
|
|
private RetrieverInterface $retriever,
|
|
private ShopSearchService $shopSearchService,
|
|
private CommerceIntentLite $commerceIntentLite,
|
|
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
|
|
{
|
|
$prompt = trim($prompt);
|
|
$swagFullOutPut = '';
|
|
$firstThinkLoop = true;
|
|
$shopResults = [];
|
|
|
|
if ($prompt === '') {
|
|
yield '❌ Empty prompt.';
|
|
return;
|
|
}
|
|
|
|
$this->agentLogger->info('Agent run started', [
|
|
'userId' => $userId,
|
|
]);
|
|
|
|
try {
|
|
// ---------------------------------------------------------
|
|
// 1) Context strategy
|
|
// ---------------------------------------------------------
|
|
|
|
if ($includeFullContext) {
|
|
//Coming soon
|
|
}
|
|
|
|
yield $this->systemMsg("Ich analysiere deine Anfrage...", "think");
|
|
|
|
// ---------------------------------------------------------
|
|
// 2) Extract URL content (if present)
|
|
// ---------------------------------------------------------
|
|
yield $this->systemMsg("Ich prüfe auf Internet Quellen...", "think");
|
|
$urlContent = $this->urlAnalyzer->extractContentFromPrompt($prompt);
|
|
|
|
// ---------------------------------------------------------
|
|
// 3) Retrieve RAG knowledge
|
|
// ---------------------------------------------------------
|
|
yield $this->systemMsg("Ich hole relevante Daten aus meinem RAG Wissen...", "think");
|
|
$knowledgeChunks = $this->retriever->retrieve($prompt);
|
|
|
|
// ---------------------------------------------------------
|
|
// 4) commerce/shop search
|
|
// ---------------------------------------------------------
|
|
|
|
$commerceMeta = $this->commerceIntentLite->detect($prompt);
|
|
$commerceIntent = (string)($commerceMeta['intent'] ?? CommerceIntentLite::NONE);
|
|
|
|
if ($commerceIntent === CommerceIntentLite::PRODUCT_SEARCH || $commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH) {
|
|
//PreOptimize swag search query
|
|
$promptSwagSearch = $this->agentRunnerConfig->getShopPrompt($prompt);
|
|
|
|
//Reset thinkSuppressor
|
|
$this->thinkSuppressor->reset();
|
|
|
|
yield $this->systemMsg("Ich optimere die Recherche...", "think");
|
|
|
|
//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,
|
|
]);
|
|
|
|
$shopResults = [];
|
|
yield $this->systemMsg('Shopdaten konnten nicht geladen werden, ich antworte mit Wissensbasis weiter...', 'think');
|
|
}
|
|
}
|
|
|
|
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");
|
|
|
|
// ---------------------------------------------------------
|
|
// 5) Build final prompt
|
|
// ---------------------------------------------------------
|
|
$finalPrompt = $this->promptBuilder->build(
|
|
prompt: $prompt,
|
|
userId: $userId,
|
|
urlContent: $urlContent,
|
|
knowledgeChunks: $knowledgeChunks,
|
|
shopResults: $shopResults,
|
|
fullContext: $includeFullContext,
|
|
swagFullOutPut: $swagFullOutPut
|
|
);
|
|
|
|
if ($this->debug && $this->logPrompt) {
|
|
$this->agentLogger->debug($finalPrompt);
|
|
}
|
|
|
|
if ($this->debug && $this->logContext) {
|
|
$this->agentLogger->debug('Conversation context snapshot', [
|
|
'context' => $this->contextService->buildUserContext(
|
|
$userId,
|
|
$includeFullContext
|
|
),
|
|
]);
|
|
}
|
|
|
|
// ---------------------------------------------------------
|
|
// 6) Stream tokens from the LLM backend (chunked streaming)
|
|
// ---------------------------------------------------------
|
|
$fullOutput = '';
|
|
$chunker = new StreamChunker();
|
|
$chunker->flush();
|
|
$this->thinkSuppressor->reset();
|
|
|
|
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');
|
|
}
|
|
|
|
// ---------------------------------------------------------
|
|
// 7) Persist conversation history
|
|
// ---------------------------------------------------------
|
|
$this->contextService->appendHistory(
|
|
$userId,
|
|
$prompt,
|
|
$fullOutput
|
|
);
|
|
|
|
$this->agentLogger->info('Agent run finished', [
|
|
'userId' => $userId,
|
|
'outputLength' => mb_strlen($fullOutput),
|
|
'contextMode' => 'recent',
|
|
'commerceIntent' => $commerceIntent,
|
|
'shopResultsCount' => count($shopResults),
|
|
]);
|
|
} catch (Throwable $e) {
|
|
$this->agentLogger->error('Agent run failed', [
|
|
'userId' => $userId,
|
|
'exception' => $e,
|
|
]);
|
|
|
|
yield $this->systemMsg("\n❌ An internal error occurred while processing the request. \nError: " . $e->getMessage(), 'err');
|
|
}
|
|
}
|
|
|
|
private function systemMsg(string $msg, string $type = ''): string
|
|
{
|
|
if (!$this->systemMsgOn) {
|
|
return '';
|
|
}
|
|
|
|
return match ($type) {
|
|
'answer' => '' . $msg,
|
|
'err' => '<span class="text-danger">' . $msg . "</span>\n<hr>\n",
|
|
'think' => '<span class="text-info think">' . $msg . "</span>\n"
|
|
};
|
|
}
|
|
} |