diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index 81fd505..89d3019 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -17,6 +17,8 @@ use Throwable; final readonly class AgentRunner { + private const COMMERCE_HISTORY_BUDGET_CHARS = 1000; + private bool $systemMsgOn; public function __construct( @@ -46,7 +48,6 @@ final readonly class AgentRunner return; } - $shopResults = []; $sources = []; $optimizedShopQuery = ''; @@ -94,7 +95,18 @@ final readonly class AgentRunner if ($this->isCommerceIntent($commerceIntent)) { yield $this->systemMsg('Ich optimiere die Recherche...', 'think'); - $optimizedShopQuery = $this->buildOptimizedShopQuery($prompt, $userId); + $commerceHistoryContext = $this->buildCommerceHistoryContext($userId); + + if($commerceHistoryContext){ + $this->addSource($sources, 'Chatverlauf'); + } + + $optimizedShopQuery = $this->buildOptimizedShopQuery( + $prompt, + $userId, + $commerceHistoryContext + ); + $shopSearchQuery = $optimizedShopQuery !== '' ? $optimizedShopQuery : $prompt; yield $this->systemMsg( @@ -102,7 +114,12 @@ final readonly class AgentRunner 'think' ); - $shopResults = $this->searchShop($shopSearchQuery, $commerceIntent, $userId); + $shopResults = $this->searchShop( + $shopSearchQuery, + $commerceIntent, + $userId, + $commerceHistoryContext + ); if ($shopResults !== []) { $this->addSource($sources, 'Shopsystem'); @@ -157,8 +174,8 @@ final readonly class AgentRunner yield $this->emitSources($sources, 'Quellen: '); } - if($this->debug){ - yield $this->systemMsg($this->systemMsg($finalPrompt), 'debug'); + if ($this->debug) { + yield $this->systemMsg($finalPrompt, 'debug'); } // --------------------------------------------------------- @@ -205,9 +222,15 @@ final readonly class AgentRunner || $commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH; } - private function buildOptimizedShopQuery(string $prompt, string $userId): string - { - $shopPrompt = trim($this->agentRunnerConfig->getShopPrompt($prompt)); + private function buildOptimizedShopQuery( + string $prompt, + string $userId, + string $commerceHistoryContext = '' + ): string { + $shopPrompt = trim($this->agentRunnerConfig->getShopPrompt( + $prompt, + $commerceHistoryContext + )); if ($shopPrompt === '') { return ''; @@ -242,10 +265,18 @@ final readonly class AgentRunner return trim($optimizedQuery); } - private function searchShop(string $query, string $commerceIntent, string $userId): array - { + private function searchShop( + string $query, + string $commerceIntent, + string $userId, + string $commerceHistoryContext = '' + ): array { try { - return $this->shopSearchService->search($query, $commerceIntent); + return $this->shopSearchService->search( + $query, + $commerceIntent, + $commerceHistoryContext + ); } catch (Throwable $e) { $this->agentLogger->warning('Shop search failed, continuing without shop results', [ 'userId' => $userId, @@ -258,6 +289,14 @@ final readonly class AgentRunner } } + private function buildCommerceHistoryContext(string $userId): string + { + return $this->contextService->buildUserContextWithinBudget( + $userId, + self::COMMERCE_HISTORY_BUDGET_CHARS + ); + } + private function limitKnowledgeChunks(array $knowledgeChunks, string $commerceIntent): array { return match ($commerceIntent) { @@ -361,7 +400,7 @@ final readonly class AgentRunner 'err' => '' . $msg . "\n
\n", 'think' => '' . $msg . "\n", 'info' => "\n\n" . $msg . "\n", - 'debug' => "\n\nDEBUG: " . $msg . "\n", + 'debug' => "\n\nDEBUG: " . htmlspecialchars($msg, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n", default => $msg, }; } diff --git a/src/Agent/PromptBuilder.php b/src/Agent/PromptBuilder.php index cf7a149..02a856b 100644 --- a/src/Agent/PromptBuilder.php +++ b/src/Agent/PromptBuilder.php @@ -7,13 +7,45 @@ namespace App\Agent; use App\Commerce\Dto\ShopProductResult; use App\Context\ContextService; use App\Repository\SystemPromptRepository; +use App\Service\ModelGenerationConfigProvider; use DateTimeImmutable; +use RuntimeException; final readonly class PromptBuilder { + /** + * Approximate character-to-token ratio for conservative prompt budgeting. + */ + private const CHARS_PER_TOKEN = 4; + + /** + * Keep a small gap so history does not consume the last available prompt space. + */ + private const HISTORY_PADDING_CHARS = 400; + + /** + * Reserve some space for the model output. + */ + private const OUTPUT_RESERVE_RATIO = 0.25; + private const OUTPUT_RESERVE_MIN_TOKENS = 768; + private const OUTPUT_RESERVE_MAX_TOKENS = 6000; + + /** + * Reserve a small safety buffer to avoid hitting the context limit too tightly. + */ + private const SAFETY_RESERVE_RATIO = 0.05; + private const SAFETY_RESERVE_MIN_TOKENS = 256; + private const SAFETY_RESERVE_MAX_TOKENS = 1024; + + /** + * Ensure the prompt budget never collapses completely on smaller models. + */ + private const MIN_PROMPT_BUDGET_TOKENS = 1024; + public function __construct( - private ContextService $contextService, + private ContextService $contextService, private SystemPromptRepository $systemPromptRepository, + private ModelGenerationConfigProvider $modelGenerationConfigProvider, ) { } @@ -26,129 +58,196 @@ final readonly class PromptBuilder * @param string $urlContent * @param string[] $knowledgeChunks * @param ShopProductResult[] $shopResults - * @param bool $fullContext + * @param bool|null $fullContext + * @param string|null $swagFullOutPut * @return string */ public function build( - string $prompt, - string $userId, - string $urlContent, - array $knowledgeChunks, - array $shopResults = [], - ?bool $fullContext = false, + string $prompt, + string $userId, + string $urlContent, + array $knowledgeChunks, + array $shopResults = [], + ?bool $fullContext = false, ?string $swagFullOutPut = '' - - ): string - { + ): string { $now = (new DateTimeImmutable())->format('Y-m-d H:i:s'); // ------------------------------------------------------------ // 1) SYSTEM INSTRUCTIONS // ------------------------------------------------------------ - $activePrompt = $this->systemPromptRepository->findActive(); if (!$activePrompt) { - throw new \RuntimeException('No active system prompt configured.'); + throw new RuntimeException('No active system prompt configured.'); } $activeSystemPrompt = str_replace('{% now %}', $now, $activePrompt->getContent()); - $systemBlock = "SYSTEM:\n" . $activeSystemPrompt; // ------------------------------------------------------------ - // 2) CONVERSATION CONTEXT (AUTHORITATIVE) + // 2) PRIORITIZED FIXED BLOCKS // ------------------------------------------------------------ - $history = $this->contextService->buildUserContext( + $shopBlock = $this->buildShopBlock($shopResults, $swagFullOutPut); + $knowledgeBlock = $this->buildKnowledgeBlock($knowledgeChunks, $urlContent); + $userBlock = "USER QUESTION:\n" . $prompt; + + // Build all fixed blocks first so history only gets the remaining budget. + $fixedBlocks = array_filter([ + $systemBlock, + $shopBlock, + $knowledgeBlock, + $userBlock, + ]); + + $fixedPrompt = implode("\n\n", $fixedBlocks); + + // ------------------------------------------------------------ + // 3) CONVERSATION CONTEXT (AUTHORITATIVE, FILLS REMAINING SPACE) + // ------------------------------------------------------------ + $contextBlock = $this->buildContextBlock( userId: $userId, - full: $fullContext + fixedPrompt: $fixedPrompt, + fullContext: (bool) $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) LIVE SHOP RESULTS (AUTHORITATIVE FOR PRODUCTS) + // 4) FINAL PROMPT ASSEMBLY // ------------------------------------------------------------ - $shopBlock = ''; - $isDetailed = !(count($shopResults) > 5); + $blocks = array_filter([ + $systemBlock, + $shopBlock, + $knowledgeBlock, + $contextBlock, + $userBlock, + ]); - if($swagFullOutPut){ - $shopBlock = "SHOP SEARCH QUERY: " . trim($swagFullOutPut)."\n"; - $shopBlock .= "Source: Shop Search\n"; - } + return implode("\n\n", $blocks); + } - if ($shopResults !== []) { - $lines = []; + /** + * Build the conversation block. + * + * If full context is requested, keep the previous behavior. + * Otherwise, history only receives the remaining prompt budget. + */ + private function buildContextBlock(string $userId, string $fixedPrompt, bool $fullContext): string + { + if ($fullContext) { + $history = $this->contextService->buildUserContext( + userId: $userId, + full: true + ); + } else { + $historyBudgetChars = $this->resolveHistoryBudgetChars($fixedPrompt); - foreach ($shopResults as $i => $product) { - if (!$product instanceof ShopProductResult) { - continue; - } - - $n = $i + 1; - $parts = [ - "[{$n}] " . $product->name, - ]; - - if ($product->productNumber) { - $parts[] = "Product number: " . $product->productNumber; - } - - if ($product->manufacturer) { - $parts[] = "Manufacturer: " . $product->manufacturer; - } - - if ($product->price) { - $parts[] = "Price: " . $product->price; - } - - if ($product->available !== null) { - $parts[] = "Available: " . ($product->available ? 'yes' : 'no'); - } - - foreach ($product->highlights as $highlight) { - $parts[] = "- " . $highlight; - } - - if ($product->url) { - $parts[] = "URL: " . $product->url; - } - - if ($product->productImage) { - $parts[] = "productImage: " . $product->productImage; - } - - if ($isDetailed && $product->description) { - $parts[] = "description: " . $product->description; - } - - if ($product->customFields) { - $parts[] = "Meta-Information: " . $product->customFields; - } - - $lines[] = implode("\n", $parts); + if ($historyBudgetChars <= 0) { + return ''; } - if ($lines !== []) { - $shopBlock .= - "LIVE SHOP RESULTS (authoritative for products):\n" . - implode("\n\n", $lines); - } + $history = $this->contextService->buildUserContextWithinBudget( + userId: $userId, + maxChars: $historyBudgetChars + ); } - // ------------------------------------------------------------ - // 4) EXTERNAL KNOWLEDGE (SUPPORTING) - // ------------------------------------------------------------ + if ($history === '') { + return ''; + } + + return + "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; + } + + /** + * Build the shop block with the highest business priority. + */ + private function buildShopBlock(array $shopResults, ?string $swagFullOutPut): string + { + $parts = []; + + if ($swagFullOutPut !== null && trim($swagFullOutPut) !== '') { + $parts[] = + "SHOP SEARCH QUERY:\n" . + trim($swagFullOutPut) . "\n" . + "Source: Shop Search"; + } + + if ($shopResults === []) { + return implode("\n\n", $parts); + } + + $isDetailed = count($shopResults) <= 5; + $lines = []; + + foreach ($shopResults as $i => $product) { + if (!$product instanceof ShopProductResult) { + continue; + } + + $n = $i + 1; + $entryParts = [ + "[{$n}] " . $product->name, + ]; + + if ($product->productNumber) { + $entryParts[] = "Product number: " . $product->productNumber; + } + + if ($product->manufacturer) { + $entryParts[] = "Manufacturer: " . $product->manufacturer; + } + + if ($product->price) { + $entryParts[] = "Price: " . $product->price; + } + + if ($product->available !== null) { + $entryParts[] = "Available: " . ($product->available ? 'yes' : 'no'); + } + + foreach ($product->highlights as $highlight) { + $entryParts[] = "- " . $highlight; + } + + if ($product->url) { + $entryParts[] = "URL: " . $product->url; + } + + if ($product->productImage) { + $entryParts[] = "Product image: " . $product->productImage; + } + + if ($isDetailed && $product->description) { + $entryParts[] = "Description: " . $product->description; + } + + if ($product->customFields) { + $entryParts[] = "Meta information: " . $product->customFields; + } + + $lines[] = implode("\n", $entryParts); + } + + if ($lines !== []) { + $parts[] = + "LIVE SHOP RESULTS (authoritative for products):\n" . + implode("\n\n", $lines); + } + + return implode("\n\n", $parts); + } + + /** + * Build the supporting knowledge block. + */ + private function buildKnowledgeBlock(array $knowledgeChunks, string $urlContent): string + { $knowledgeParts = []; - if ($knowledgeChunks !== []) { $lines = []; @@ -159,40 +258,59 @@ final readonly class PromptBuilder $knowledgeParts[] = "RETRIEVED KNOWLEDGE (supporting):\n" . - "Source: Documents \n". + "Source: Documents\n" . implode("\n\n", $lines); } if ($urlContent !== '') { $knowledgeParts[] = "CONTENT FROM URL (supporting):\n" . - "Source: URL \n". + "Source: URL\n" . $urlContent; } - $knowledgeBlock = ''; - if ($knowledgeParts !== []) { - $knowledgeBlock = implode("\n\n", $knowledgeParts); - } + return implode("\n\n", $knowledgeParts); + } - // ------------------------------------------------------------ - // 5) USER QUESTION - // ------------------------------------------------------------ - $userBlock = - "USER QUESTION:\n" . - $prompt; + /** + * Resolve how many characters may still be used by history. + * + * The active model num_ctx is converted into a conservative prompt budget. + * Shop, knowledge and user question are fixed priority blocks. + * History only receives the remaining space. + */ + private function resolveHistoryBudgetChars(string $fixedPrompt): int + { + $numCtx = $this->modelGenerationConfigProvider->getActiveNumCtx(); - // ------------------------------------------------------------ - // 6) FINAL PROMPT ASSEMBLY - // ------------------------------------------------------------ - $blocks = array_filter([ - $systemBlock, - $contextBlock, - $shopBlock, - $knowledgeBlock, - $userBlock, - ]); + $outputReserveTokens = $this->clamp( + (int) floor($numCtx * self::OUTPUT_RESERVE_RATIO), + self::OUTPUT_RESERVE_MIN_TOKENS, + self::OUTPUT_RESERVE_MAX_TOKENS + ); - return implode("\n\n", $blocks); + $safetyReserveTokens = $this->clamp( + (int) floor($numCtx * self::SAFETY_RESERVE_RATIO), + self::SAFETY_RESERVE_MIN_TOKENS, + self::SAFETY_RESERVE_MAX_TOKENS + ); + + $promptBudgetTokens = max( + self::MIN_PROMPT_BUDGET_TOKENS, + $numCtx - $outputReserveTokens - $safetyReserveTokens + ); + + $promptBudgetChars = $promptBudgetTokens * self::CHARS_PER_TOKEN; + + $remaining = $promptBudgetChars + - mb_strlen($fixedPrompt) + - self::HISTORY_PADDING_CHARS; + + return max(0, $remaining); + } + + private function clamp(int $value, int $min, int $max): int + { + return max($min, min($max, $value)); } } \ No newline at end of file diff --git a/src/Commerce/CommerceQueryParser.php b/src/Commerce/CommerceQueryParser.php index 440fcfe..83505f6 100644 --- a/src/Commerce/CommerceQueryParser.php +++ b/src/Commerce/CommerceQueryParser.php @@ -21,29 +21,61 @@ final readonly class CommerceQueryParser { } - public function parse(string $originalPrompt, string $intent): CommerceSearchQuery + public function parse( + string $originalPrompt, + string $intent, + string $historyContext = '' + ): CommerceSearchQuery { - $normalized = $this->normalize($originalPrompt); - [$priceMin, $priceMax] = $this->extractPriceRange($normalized); - $sizes = $this->extractSizes($normalized); - $brand = $this->extractBrand($normalized); - $properties = []; + $normalizedPrompt = $this->normalize($originalPrompt); + + [$priceMin, $priceMax] = $this->extractPriceRange($normalizedPrompt); + $sizes = $this->extractSizes($normalizedPrompt); + $brand = $this->extractBrand($normalizedPrompt); $searchText = $this->buildSearchText( - $normalized, + $normalizedPrompt, $sizes, $brand, $priceMin, $priceMax ); + if ($historyContext !== '' && $this->shouldUseHistoryContext($normalizedPrompt)) { + $latestHistoryQuestion = $this->extractLatestQuestionFromHistory($historyContext); + + if ($latestHistoryQuestion !== '') { + $normalizedHistoryPrompt = $this->normalize($latestHistoryQuestion); + + [$historyPriceMin, $historyPriceMax] = $this->extractPriceRange($normalizedHistoryPrompt); + $historySizes = $this->extractSizes($normalizedHistoryPrompt); + $historyBrand = $this->extractBrand($normalizedHistoryPrompt); + + $historySearchText = $this->buildSearchText( + $normalizedHistoryPrompt, + $historySizes, + $historyBrand, + $historyPriceMin, + $historyPriceMax + ); + + $searchText = $this->mergeSearchTexts($historySearchText, $searchText); + + if (($brand === null || $brand === '') && $historyBrand !== null && $historyBrand !== '') { + $brand = $historyBrand; + } + } + } + + $finalSearchText = $searchText !== '' ? $searchText : $normalizedPrompt; + return new CommerceSearchQuery( originalPrompt: $originalPrompt, - normalizedPrompt: $normalized, - searchText: $searchText !== '' ? $searchText : $normalized, + normalizedPrompt: $normalizedPrompt, + searchText: $finalSearchText, brand: $brand, sizes: $sizes, - properties: $properties, + properties: [], priceMin: $priceMin, priceMax: $priceMax, intent: $intent, @@ -138,9 +170,7 @@ final readonly class CommerceQueryParser { $text = ' ' . $prompt . ' '; - $phrasesToRemove = $this->config->getPhrasesToRemove(); - - foreach ($phrasesToRemove as $phrase) { + foreach ($this->config->getPhrasesToRemove() as $phrase) { $text = str_replace($phrase, ' ', $text); } @@ -155,16 +185,83 @@ final readonly class CommerceQueryParser if ($priceMin !== null || $priceMax !== null) { $text = preg_replace('/\bzwischen\s+\d+(?:[.,]\d+)?\s+und\s+\d+(?:[.,]\d+)?\s*euro\b/u', ' ', $text) ?? $text; $text = preg_replace('/\b(?:unter|bis|max(?:imal)?|ab|mindestens|min)\s+\d+(?:[.,]\d+)?\s*euro\b/u', ' ', $text) ?? $text; - $text = preg_replace('/\b'.$this->intentConfig->getPricePattern().'\b/u', ' ', $text) ?? $text; + $text = preg_replace('/\b' . $this->intentConfig->getPricePattern() . '\b/u', ' ', $text) ?? $text; } $text = preg_replace('/\s+/u', ' ', $text) ?? $text; $text = trim($text, " \t\n\r\0\x0B-.,"); - $tokens = array_filter(explode(' ', $text), static fn(string $token): bool => mb_strlen($token) > 1); + + $tokens = array_filter( + explode(' ', $text), + static fn(string $token): bool => mb_strlen($token) > 1 + ); + + $tokens = $this->filterSearchTokens($tokens); return trim(implode(' ', $tokens)); } + private function shouldUseHistoryContext(string $prompt): bool + { + return preg_match( + '/\b(' . $this->config->getHistoryContextPattern() . ')\b/u', + $prompt + ) === 1; + } + + private function extractLatestQuestionFromHistory(string $historyContext): string + { + if (preg_match_all('/^Question:\s*(.+)$/m', $historyContext, $matches) !== 1 && preg_match_all('/^Question:\s*(.+)$/m', $historyContext, $matches) === false) { + return ''; + } + + $questions = $matches[1] ?? []; + if ($questions === []) { + return ''; + } + + $lastQuestion = end($questions); + + return is_string($lastQuestion) ? trim($lastQuestion) : ''; + } + + private function mergeSearchTexts(string $historySearchText, string $currentSearchText): string + { + $tokens = []; + + foreach ([$historySearchText, $currentSearchText] as $text) { + if ($text === '') { + continue; + } + + foreach (explode(' ', $text) as $token) { + $token = trim($token); + + if ($token === '' || mb_strlen($token) <= 1) { + continue; + } + + $tokens[$token] = $token; + } + } + + return implode(' ', array_values($tokens)); + } + + /** + * @param string[] $tokens + * @return string[] + */ + private function filterSearchTokens(array $tokens): array + { + $stopWords = $this->config->getFilterSearchTokensPattern(); + + return array_values(array_filter( + $tokens, + static fn(string $token): bool => !in_array($token, $stopWords, true) + )); + } + private function toFloat(string $value): ?float { $value = str_replace(',', '.', trim($value)); diff --git a/src/Commerce/ShopSearchService.php b/src/Commerce/ShopSearchService.php index 81ff8de..8af88eb 100644 --- a/src/Commerce/ShopSearchService.php +++ b/src/Commerce/ShopSearchService.php @@ -15,12 +15,12 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; final readonly class ShopSearchService { public function __construct( - private CommerceQueryParser $queryParser, + private CommerceQueryParser $queryParser, private ShopwareCriteriaBuilder $criteriaBuilder, - private StoreApiClient $storeApiClient, - private bool $enabled = true, - private int $maxResults = 25, - private string $baseUrl + private StoreApiClient $storeApiClient, + private bool $enabled = true, + private int $maxResults = 25, + private string $baseUrl ) { } @@ -28,21 +28,33 @@ final readonly class ShopSearchService /** * @return ShopProductResult[] */ - public function search(string $originalPrompt, string $commerceIntent): array - { + public function search( + string $originalPrompt, + string $commerceIntent, + string $commerceHistoryContext = '' + ): array { if (!$this->enabled) { return []; } $response = []; - $query = $this->queryParser->parse($originalPrompt, $commerceIntent); + $query = $this->queryParser->parse( + $originalPrompt, + $commerceIntent, + $commerceHistoryContext + ); + $criteria = $this->criteriaBuilder->build($query, $this->maxResults); try { $response = $this->storeApiClient->searchProducts($criteria); - } catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $e) { - + } catch ( + ClientExceptionInterface + | RedirectionExceptionInterface + | ServerExceptionInterface + | TransportExceptionInterface $e + ) { } return $this->mapProducts($response); @@ -66,12 +78,12 @@ final readonly class ShopSearchService } $results[] = new ShopProductResult( - id: (string)($row['id'] ?? ''), - name: trim((string)($row['translated']['name'] ?? '')), - productNumber: isset($row['productNumber']) ? (string)$row['productNumber'] : null, + id: (string) ($row['id'] ?? ''), + name: trim((string) ($row['translated']['name'] ?? '')), + productNumber: isset($row['productNumber']) ? (string) $row['productNumber'] : null, manufacturer: $this->extractManufacturer($row), price: $this->extractPrice($row), - available: isset($row['available']) ? (bool)$row['available'] : null, + available: isset($row['available']) ? (bool) $row['available'] : null, url: $this->baseUrl . $this->extractUrl($row), highlights: $this->extractHighlights($row), description: $this->cleanUpDescription($row), @@ -98,11 +110,11 @@ final readonly class ShopSearchService private function cleanUpDescription(array $description): string { if (isset($description['translated']['description'])) { - $newDesc = strip_tags((string)$description['translated']['description']); + $newDesc = strip_tags((string) $description['translated']['description']); $newDesc = html_entity_decode($newDesc); $newDesc = preg_replace('/^[ \t]*\R/m', '', $newDesc); $newDesc = preg_replace('/[ \t]{2,}/', ' ', $newDesc); - $result = trim((string)$newDesc); + $result = trim((string) $newDesc); return mb_substr($result, 0, 1500); } @@ -142,7 +154,7 @@ final readonly class ShopSearchService return null; } - return number_format((float)$unitPrice, 2, ',', '.') . ' €'; + return number_format((float) $unitPrice, 2, ',', '.') . ' €'; } private function extractUrl(array $row): ?string @@ -175,7 +187,7 @@ final readonly class ShopSearchService $highlights = []; if (isset($row['available'])) { - $highlights[] = (bool)$row['available'] ? 'Verfügbar' : 'Nicht verfügbar'; + $highlights[] = (bool) $row['available'] ? 'Verfügbar' : 'Nicht verfügbar'; } if (isset($row['productNumber']) && is_string($row['productNumber']) && trim($row['productNumber']) !== '') { diff --git a/src/Config/AgentRunnerConfig.php b/src/Config/AgentRunnerConfig.php index b09ee98..847d91e 100644 --- a/src/Config/AgentRunnerConfig.php +++ b/src/Config/AgentRunnerConfig.php @@ -1,14 +1,30 @@ readHistoryFile($userId); + if ($history === '') { + return ''; + } + + $turns = $this->splitHistoryIntoTurns($history); + if ($turns === []) { + return $this->truncateTurnToBudget($history, $maxChars); + } + + $selected = []; + $currentLength = 0; + + for ($i = count($turns) - 1; $i >= 0; $i--) { + $turn = $turns[$i]; + $turnLength = mb_strlen($turn); + $separatorLength = $selected === [] ? 0 : 2; // "\n\n" + + if (($currentLength + $separatorLength + $turnLength) <= $maxChars) { + array_unshift($selected, $turn); + $currentLength += $separatorLength + $turnLength; + continue; + } + + // If nothing fits yet, keep at least the newest turn in truncated form. + if ($selected === []) { + $truncatedTurn = $this->truncateTurnToBudget($turn, $maxChars); + if ($truncatedTurn !== '') { + $selected[] = $truncatedTurn; + } + } + + break; + } + + return implode("\n\n", $selected); + } + /** * Appends a completed interaction to the user's history. * @@ -105,6 +162,82 @@ final class ContextService } } + /** + * Reads the raw history file contents for a user. + */ + private function readHistoryFile(string $userId): string + { + $path = $this->getHistoryPath($userId); + + if (!is_file($path)) { + return ''; + } + + $content = file_get_contents($path); + + if ($content === false) { + return ''; + } + + return trim($content); + } + + /** + * Splits append-only history into complete turns. + * + * Each turn starts with "Question: ". + */ + private function splitHistoryIntoTurns(string $history): array + { + $history = trim($history); + if ($history === '') { + return []; + } + + $parts = preg_split('/(?=^Question:\s)/m', $history); + if ($parts === false) { + return []; + } + + $turns = []; + + foreach ($parts as $part) { + $part = trim($part); + + if ($part === '') { + continue; + } + + $turns[] = $part; + } + + return $turns; + } + + /** + * Truncates a single turn to fit into the available budget. + * + * The start of the turn is preserved so the "Question: ..." marker remains intact. + */ + private function truncateTurnToBudget(string $turn, int $maxChars): string + { + $turn = trim($turn); + + if ($turn === '' || $maxChars <= 0) { + return ''; + } + + if (mb_strlen($turn) <= $maxChars) { + return $turn; + } + + if ($maxChars <= 3) { + return mb_substr($turn, 0, $maxChars); + } + + return rtrim(mb_substr($turn, 0, $maxChars - 3)) . '...'; + } + /** * Resolves the absolute history file path for a user. */ @@ -114,4 +247,4 @@ final class ContextService return $this->historyDir . '/' . $safeUserId . '.txt'; } -} +} \ No newline at end of file diff --git a/src/Service/ModelGenerationConfigProvider.php b/src/Service/ModelGenerationConfigProvider.php index 825bc3d..e487959 100644 --- a/src/Service/ModelGenerationConfigProvider.php +++ b/src/Service/ModelGenerationConfigProvider.php @@ -1,6 +1,5 @@ getActiveForModel()->getNumCtx(); + + return max(512, $numCtx); + } +} \ No newline at end of file