first commit

This commit is contained in:
team 1
2026-02-11 14:15:08 +01:00
parent a4742c2c38
commit aa7d362bc3
58 changed files with 9999 additions and 0 deletions

136
src/Agent/AgentRunner.php Normal file
View 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
View 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);
}
}

View 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;
}
}

View 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 = '';
}
}