add history to shop search
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user