first commit
This commit is contained in:
136
src/Agent/AgentRunner.php
Normal file
136
src/Agent/AgentRunner.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Agent;
|
||||
|
||||
use App\Context\ContextService;
|
||||
use App\Context\UrlAnalyzer;
|
||||
use App\Infrastructure\OllamaClient;
|
||||
use App\Knowledge\Retrieval\RetrieverInterface;
|
||||
use Generator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
use App\Agent\StreamChunker;
|
||||
|
||||
final readonly class AgentRunner
|
||||
{
|
||||
public function __construct(
|
||||
private PromptBuilder $promptBuilder,
|
||||
private ThinkSuppressor $thinkSuppressor,
|
||||
private ContextService $contextService,
|
||||
private UrlAnalyzer $urlAnalyzer,
|
||||
private RetrieverInterface $retriever,
|
||||
private OllamaClient $ollamaClient,
|
||||
private LoggerInterface $agentLogger,
|
||||
private bool $debug,
|
||||
private bool $logPrompt,
|
||||
private bool $logContext,
|
||||
) {}
|
||||
|
||||
public function run(string $prompt, string $userId): Generator
|
||||
{
|
||||
$prompt = trim($prompt);
|
||||
|
||||
if ($prompt === '') {
|
||||
yield '❌ Empty prompt.';
|
||||
return;
|
||||
}
|
||||
|
||||
$this->agentLogger->info('Agent run started', [
|
||||
'userId' => $userId,
|
||||
]);
|
||||
|
||||
try {
|
||||
// ---------------------------------------------------------
|
||||
// 1) Context strategy
|
||||
// ---------------------------------------------------------
|
||||
$includeFullContext = false;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 2) Extract URL content (if present)
|
||||
// ---------------------------------------------------------
|
||||
$urlContent = $this->urlAnalyzer->extractContentFromPrompt($prompt);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3) Retrieve RAG knowledge
|
||||
// ---------------------------------------------------------
|
||||
$knowledgeChunks = $this->retriever->retrieve($prompt);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 4) Build final prompt
|
||||
// ---------------------------------------------------------
|
||||
$finalPrompt = $this->promptBuilder->build(
|
||||
prompt: $prompt,
|
||||
userId: $userId,
|
||||
urlContent: $urlContent,
|
||||
knowledgeChunks: $knowledgeChunks,
|
||||
fullContext: $includeFullContext
|
||||
);
|
||||
|
||||
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
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 5) Stream tokens from the LLM backend (chunked streaming)
|
||||
// ---------------------------------------------------------
|
||||
$fullOutput = '';
|
||||
$chunker = new StreamChunker();
|
||||
|
||||
foreach ($this->ollamaClient->stream($finalPrompt) as $token) {
|
||||
$cleanToken = $this->thinkSuppressor->filter($token);
|
||||
|
||||
if ($cleanToken === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Vollständige Antwort weiter sammeln (für History)
|
||||
$fullOutput .= $cleanToken;
|
||||
|
||||
// ⬇️ Token in Chunker geben
|
||||
$chunk = $chunker->push($cleanToken);
|
||||
if ($chunk !== null) {
|
||||
yield $chunk;
|
||||
}
|
||||
}
|
||||
|
||||
// ⬇️ Rest flushen
|
||||
$finalChunk = $chunker->flush();
|
||||
if ($finalChunk !== null) {
|
||||
yield $finalChunk;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 6) Persist conversation history
|
||||
// ---------------------------------------------------------
|
||||
$this->contextService->appendHistory(
|
||||
$userId,
|
||||
$prompt,
|
||||
$fullOutput
|
||||
);
|
||||
|
||||
$this->agentLogger->info('Agent run finished', [
|
||||
'userId' => $userId,
|
||||
'outputLength' => mb_strlen($fullOutput),
|
||||
'contextMode' => 'recent',
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->agentLogger->error('Agent run failed', [
|
||||
'userId' => $userId,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
yield "\n❌ An internal error occurred while processing the request.";
|
||||
}
|
||||
}
|
||||
}
|
||||
136
src/Agent/PromptBuilder.php
Normal file
136
src/Agent/PromptBuilder.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Agent;
|
||||
|
||||
use App\Context\ContextService;
|
||||
use App\Context\UrlAnalyzer;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class PromptBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContextService $contextService,
|
||||
private readonly UrlAnalyzer $urlAnalyzer,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final prompt string for the LLM.
|
||||
*
|
||||
* @param string $prompt
|
||||
* @param string $userId
|
||||
* @param string $urlContent
|
||||
* @param string[] $knowledgeChunks
|
||||
* @param bool $fullContext
|
||||
*/
|
||||
public function build(
|
||||
string $prompt,
|
||||
string $userId,
|
||||
string $urlContent,
|
||||
array $knowledgeChunks,
|
||||
bool $fullContext = false,
|
||||
): string
|
||||
{
|
||||
$now = (new DateTimeImmutable())->format('Y-m-d H:i:s');
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 1) SYSTEM INSTRUCTIONS
|
||||
// ------------------------------------------------------------
|
||||
$systemLines = [
|
||||
'You are a conversational AI assistant.',
|
||||
'Respond clearly, precisely, and in context of the ongoing conversation.',
|
||||
'The conversation context is authoritative and must be respected.',
|
||||
'External knowledge is supporting information only.',
|
||||
'If the user asks for contact details such as phone number, email address, postal address or contact person, and the provided context contains such information, answer explicitly with the concrete data.',
|
||||
'Do not omit contact details.',
|
||||
'It is allowed and desired to quote contact data verbatim if it appears in the context.',
|
||||
"Current date and time: {$now}",
|
||||
'',
|
||||
'IMPORTANT FORMATTING RULES:',
|
||||
'- Always answer in valid Markdown.',
|
||||
'- Use headings, lists, and paragraphs where appropriate.',
|
||||
'- Insert line breaks early and often.',
|
||||
'- Never write long paragraphs without newlines.',
|
||||
'- Each list item must start on a new line.',
|
||||
'- Prefer short paragraphs over dense text blocks.',
|
||||
'',
|
||||
'IMPORTANT LANGUAGE RULES:',
|
||||
'- If the user input contains misspellings, silently use the correct canonical terms in your answer.',
|
||||
'- Never mention, explain, or point out spelling mistakes.',
|
||||
'- Do not ask clarifying questions about possible misspellings.',
|
||||
'- Do not repeat or quote misspelled terms from the user input.',
|
||||
'- Always use the correct technical spelling found in the provided context.',
|
||||
'- Answer directly and confidently using always correct canonical terminology.'
|
||||
];
|
||||
|
||||
$systemBlock = "SYSTEM:\n" . implode("\n", $systemLines);
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 2) CONVERSATION CONTEXT (AUTHORITATIVE)
|
||||
// ------------------------------------------------------------
|
||||
$history = $this->contextService->buildUserContext(
|
||||
userId: $userId,
|
||||
full: $fullContext
|
||||
);
|
||||
|
||||
$contextBlock = '';
|
||||
if ($history !== '') {
|
||||
$contextBlock =
|
||||
"CONVERSATION CONTEXT (authoritative):\n" .
|
||||
"The following messages are the previous turns of this conversation.\n" .
|
||||
"They must be considered when answering the next question.\n\n" .
|
||||
$history;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 3) EXTERNAL KNOWLEDGE (SUPPORTING)
|
||||
// ------------------------------------------------------------
|
||||
$knowledgeParts = [];
|
||||
|
||||
if ($knowledgeChunks !== []) {
|
||||
$lines = [];
|
||||
|
||||
foreach ($knowledgeChunks as $i => $chunk) {
|
||||
$n = $i + 1;
|
||||
$lines[] = "[{$n}] {$chunk}";
|
||||
}
|
||||
|
||||
$knowledgeParts[] =
|
||||
"RETRIEVED KNOWLEDGE (supporting):\n" .
|
||||
implode("\n\n", $lines);
|
||||
}
|
||||
|
||||
if ($urlContent !== '') {
|
||||
$knowledgeParts[] =
|
||||
"CONTENT FROM URL (supporting):\n" .
|
||||
$urlContent;
|
||||
}
|
||||
|
||||
$knowledgeBlock = '';
|
||||
if ($knowledgeParts !== []) {
|
||||
$knowledgeBlock = implode("\n\n", $knowledgeParts);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 4) USER QUESTION
|
||||
// ------------------------------------------------------------
|
||||
$userBlock =
|
||||
"USER QUESTION:\n" .
|
||||
$prompt;
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 5) FINAL PROMPT ASSEMBLY
|
||||
// ------------------------------------------------------------
|
||||
$blocks = array_filter([
|
||||
$systemBlock,
|
||||
$contextBlock,
|
||||
$knowledgeBlock,
|
||||
$userBlock,
|
||||
]);
|
||||
|
||||
return implode("\n\n", $blocks);
|
||||
}
|
||||
}
|
||||
61
src/Agent/StreamChunker.php
Normal file
61
src/Agent/StreamChunker.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Agent;
|
||||
|
||||
final class StreamChunker
|
||||
{
|
||||
private string $buffer = '';
|
||||
private bool $insideCodeBlock = false;
|
||||
private int $minChunkSize = 120;
|
||||
|
||||
public function push(string $token): ?string
|
||||
{
|
||||
$this->buffer .= $token;
|
||||
|
||||
if (str_contains($token, '```')) {
|
||||
$this->insideCodeBlock = !$this->insideCodeBlock;
|
||||
}
|
||||
|
||||
if ($this->shouldFlush()) {
|
||||
$out = $this->buffer;
|
||||
$this->buffer = '';
|
||||
return $out;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function flush(): ?string
|
||||
{
|
||||
if ($this->buffer === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$out = $this->buffer;
|
||||
$this->buffer = '';
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function shouldFlush(): bool
|
||||
{
|
||||
if ($this->insideCodeBlock) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_ends_with($this->buffer, "\n\n")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('/[.!?]\s$/', $this->buffer)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('/\n[-*] .+\n$/', $this->buffer)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return mb_strlen($this->buffer) >= $this->minChunkSize;
|
||||
}
|
||||
}
|
||||
88
src/Agent/ThinkSuppressor.php
Normal file
88
src/Agent/ThinkSuppressor.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Agent;
|
||||
|
||||
/**
|
||||
* ThinkSuppressor
|
||||
*
|
||||
* Robust streaming-safe suppressor for internal <think>...</think> sections.
|
||||
*
|
||||
* Key properties:
|
||||
* - Handles token fragmentation (partial tags across tokens)
|
||||
* - Stateful per stream, stateless per request
|
||||
* - Does not buffer full responses
|
||||
* - Deterministic and predictable
|
||||
*/
|
||||
final class ThinkSuppressor
|
||||
{
|
||||
/** Indicates whether the stream is currently inside a <think> block. */
|
||||
private bool $insideThink = false;
|
||||
|
||||
/** Indicates whether the think section has been fully closed. */
|
||||
private bool $thinkSectionCompleted = false;
|
||||
|
||||
/**
|
||||
* Rolling buffer for detecting fragmented tags across tokens.
|
||||
*/
|
||||
private string $rollingBuffer = '';
|
||||
|
||||
/**
|
||||
* Maximum buffer length needed to safely detect tags.
|
||||
*/
|
||||
private int $maxBufferLength = 32;
|
||||
|
||||
/**
|
||||
* Filters a single token from the LLM stream.
|
||||
*
|
||||
* @param string $token Raw token from the LLM
|
||||
* @return string Cleaned token safe for user output
|
||||
*/
|
||||
public function filter(string $token): string
|
||||
{
|
||||
// Append to rolling buffer
|
||||
$this->rollingBuffer .= $token;
|
||||
if (strlen($this->rollingBuffer) > $this->maxBufferLength) {
|
||||
$this->rollingBuffer = substr($this->rollingBuffer, -$this->maxBufferLength);
|
||||
}
|
||||
|
||||
// If think section is already completed, just strip stray closing tags
|
||||
if ($this->thinkSectionCompleted) {
|
||||
return str_replace('</think>', '', $token);
|
||||
}
|
||||
|
||||
// Detect fragmented opening <think> tag
|
||||
if (!$this->insideThink && str_contains($this->rollingBuffer, '<think>')) {
|
||||
$this->insideThink = true;
|
||||
return '';
|
||||
}
|
||||
|
||||
// Detect fragmented closing </think> tag
|
||||
if ($this->insideThink && str_contains($this->rollingBuffer, '</think>')) {
|
||||
$this->insideThink = false;
|
||||
$this->thinkSectionCompleted = true;
|
||||
|
||||
// Emit a single line break after think section ends
|
||||
return "\n";
|
||||
}
|
||||
|
||||
// Suppress all content while inside <think>...</think>
|
||||
if ($this->insideThink) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the suppressor state.
|
||||
* Must be called before starting a new stream.
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->insideThink = false;
|
||||
$this->thinkSectionCompleted = false;
|
||||
$this->rollingBuffer = '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user