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

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