add history to shop search

This commit is contained in:
team 1
2026-04-17 12:59:21 +02:00
parent bab3682975
commit ae2b52ad18
8 changed files with 630 additions and 172 deletions

View File

@@ -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' => '<span class="text-danger">' . $msg . "</span>\n<hr>\n",
'think' => '<span class="text-info think">' . $msg . "</span>\n",
'info' => "\n\n<span class=\"text-info fw-bolder\">" . $msg . "</span>\n",
'debug' => "\n\nDEBUG: <code>" . $msg . "</code>\n",
'debug' => "\n\nDEBUG: <code>" . htmlspecialchars($msg, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</code>\n",
default => $msg,
};
}

View File

@@ -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));
}
}

View File

@@ -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));

View File

@@ -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']) !== '') {

View File

@@ -1,14 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Config;
class AgentRunnerConfig
final class AgentRunnerConfig
{
public function getShopPrompt($prompt): string
public function getShopPrompt(string $prompt, string $commerceHistoryContext = ''): string
{
/**
* Erzeuge aus dem folgenden Nutzereingabetext einen kurzen Suchtext für die Shopware-6-Suche. Regeln: - Gib nur den finalen Suchtext aus. - erstelle immer die singular form von den relevanten Suchbegriffen - Keine Einleitung, keine Erklärung, keine Anführungszeichen. - Verwende nur die shop relevanten Suchbegriffe für eine Shopsuche aus dem Nutzereingabetext. - Maximal 6 Suchbegriffe, besser weniger. - Entferne Füllwörter, Höflichkeitsformen und irrelevante Wörter. - Erhalte Produktnamen, Marken, Modellnummern und zusammengesetzte Begriffe exakt, wenn sie relevant sind. - Zahlen, die zu einem Produktnamen oder Modell gehören (zb Indikator 300 oder Testomat 808 oder Testomat 2000), müssen erhalten bleiben. - Trenne die Begriffe nur durch Leerzeichen. Ausgabeformat: Keyword1 Keyword2 Keyword3
*/
$historyBlock = '';
if (trim($commerceHistoryContext) !== '') {
$historyBlock = '
RECENT CONVERSATION CONTEXT:
' . $commerceHistoryContext . '
Additional rules for conversation context:
- The current user input has highest priority.
- Use the recent conversation context only to resolve omitted references.
- Use it only for product carry-over, brand carry-over, model carry-over, or variant follow-ups.
- Do not revive older products unless the current user input clearly refers to them.
- If the current input starts a new topic, ignore older product context.
- Prefer the most recent product reference over older ones.
';
}
return '
Generate a short search query for Shopware 6 from the following user input text.
@@ -23,11 +39,17 @@ class AgentRunnerConfig
- Numbers that belong to a product name or model must be preserved (e.g. Indikator 300, Testomat 808, Testomat 2000).
- Separate terms using spaces only.
- If a relevant product name is present, it must be placed at the beginning of the final search query.
- Try to always identify all products mentioned in the user input text, even in long prompts.
- Look for terms such as Testomat, Horiba, Tritromat, or words like indicator.
- If the current user input is vague or referential, use the recent conversation context only as support.
- Do not output words that only describe conversation flow, such as "same", "again", "also", or "like above".
Output format:
Keyword1 Keyword2 Keyword3
' . $historyBlock . '
input text: ' . $prompt . '
CURRENT USER INPUT:
' . $prompt . '
';
}
}

View File

@@ -13,7 +13,8 @@ class CommerceQueryParserConfig
];
}
public function getPhrasesToRemove(): array{
public function getPhrasesToRemove(): array
{
return [
'ich suche',
'suche',
@@ -29,4 +30,34 @@ class CommerceQueryParserConfig
'alternativen',
];
}
public function getHistoryContextPattern(): string
{
return 'chat|auch|noch|nochmal|zusätzlich|dazu|davon|stattdessen|alternative|alternativen|größer|groesser|kleiner|gleich(?:e|en|er|es)?|derselbe|dieselbe|dasselbe|wie oben|wie zuvor|wie gehabt';
}
public function getFilterSearchTokensPattern(): array
{
return [
'auch',
'noch',
'nochmal',
'zusätzlich',
'dazu',
'davon',
'stattdessen',
'bitte',
'gern',
'gerne',
'zeige',
'zeig',
'such',
'suche',
'finde',
'find',
'mir',
'mal',
'von',
];
}
}

View File

@@ -72,12 +72,69 @@ final class ContextService
return '';
}
$maxLines = $full ? ContextServiceConfig::MAX_FULL_LINES : ContextServiceConfig::MAX_VISIBLE_REGULAR_LINES;
$maxLines = $full
? ContextServiceConfig::MAX_FULL_LINES
: ContextServiceConfig::MAX_VISIBLE_REGULAR_LINES;
$selected = array_slice($lines, -$maxLines);
return implode("\n", $selected);
}
/**
* Returns as much recent history as possible within a character budget.
*
* Strategy:
* - Preserve complete conversation turns whenever possible
* - Prioritize the newest turns
* - Only truncate when even the newest single turn is larger than the budget
*
* A turn starts with "Question: " and includes the corresponding assistant answer.
*/
public function buildUserContextWithinBudget(string $userId, int $maxChars): string
{
if ($maxChars <= 0) {
return '';
}
$history = $this->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.
*/

View File

@@ -1,6 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Service;
@@ -8,7 +7,7 @@ namespace App\Service;
use App\Entity\ModelGenerationConfig;
use App\Repository\ModelGenerationConfigRepository;
final class ModelGenerationConfigProvider
final readonly class ModelGenerationConfigProvider
{
public function __construct(
private ModelGenerationConfigRepository $repository
@@ -39,4 +38,11 @@ final class ModelGenerationConfigProvider
active: false
);
}
public function getActiveNumCtx(): int
{
$numCtx = (int) $this->getActiveForModel()->getNumCtx();
return max(512, $numCtx);
}
}