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
" . $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