add history to shop search
This commit is contained in:
@@ -17,6 +17,8 @@ use Throwable;
|
|||||||
|
|
||||||
final readonly class AgentRunner
|
final readonly class AgentRunner
|
||||||
{
|
{
|
||||||
|
private const COMMERCE_HISTORY_BUDGET_CHARS = 1000;
|
||||||
|
|
||||||
private bool $systemMsgOn;
|
private bool $systemMsgOn;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -46,7 +48,6 @@ final readonly class AgentRunner
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
$shopResults = [];
|
$shopResults = [];
|
||||||
$sources = [];
|
$sources = [];
|
||||||
$optimizedShopQuery = '';
|
$optimizedShopQuery = '';
|
||||||
@@ -94,7 +95,18 @@ final readonly class AgentRunner
|
|||||||
if ($this->isCommerceIntent($commerceIntent)) {
|
if ($this->isCommerceIntent($commerceIntent)) {
|
||||||
yield $this->systemMsg('Ich optimiere die Recherche...', 'think');
|
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;
|
$shopSearchQuery = $optimizedShopQuery !== '' ? $optimizedShopQuery : $prompt;
|
||||||
|
|
||||||
yield $this->systemMsg(
|
yield $this->systemMsg(
|
||||||
@@ -102,7 +114,12 @@ final readonly class AgentRunner
|
|||||||
'think'
|
'think'
|
||||||
);
|
);
|
||||||
|
|
||||||
$shopResults = $this->searchShop($shopSearchQuery, $commerceIntent, $userId);
|
$shopResults = $this->searchShop(
|
||||||
|
$shopSearchQuery,
|
||||||
|
$commerceIntent,
|
||||||
|
$userId,
|
||||||
|
$commerceHistoryContext
|
||||||
|
);
|
||||||
|
|
||||||
if ($shopResults !== []) {
|
if ($shopResults !== []) {
|
||||||
$this->addSource($sources, 'Shopsystem');
|
$this->addSource($sources, 'Shopsystem');
|
||||||
@@ -157,8 +174,8 @@ final readonly class AgentRunner
|
|||||||
yield $this->emitSources($sources, 'Quellen: ');
|
yield $this->emitSources($sources, 'Quellen: ');
|
||||||
}
|
}
|
||||||
|
|
||||||
if($this->debug){
|
if ($this->debug) {
|
||||||
yield $this->systemMsg($this->systemMsg($finalPrompt), 'debug');
|
yield $this->systemMsg($finalPrompt, 'debug');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@@ -205,9 +222,15 @@ final readonly class AgentRunner
|
|||||||
|| $commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH;
|
|| $commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildOptimizedShopQuery(string $prompt, string $userId): string
|
private function buildOptimizedShopQuery(
|
||||||
{
|
string $prompt,
|
||||||
$shopPrompt = trim($this->agentRunnerConfig->getShopPrompt($prompt));
|
string $userId,
|
||||||
|
string $commerceHistoryContext = ''
|
||||||
|
): string {
|
||||||
|
$shopPrompt = trim($this->agentRunnerConfig->getShopPrompt(
|
||||||
|
$prompt,
|
||||||
|
$commerceHistoryContext
|
||||||
|
));
|
||||||
|
|
||||||
if ($shopPrompt === '') {
|
if ($shopPrompt === '') {
|
||||||
return '';
|
return '';
|
||||||
@@ -242,10 +265,18 @@ final readonly class AgentRunner
|
|||||||
return trim($optimizedQuery);
|
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 {
|
try {
|
||||||
return $this->shopSearchService->search($query, $commerceIntent);
|
return $this->shopSearchService->search(
|
||||||
|
$query,
|
||||||
|
$commerceIntent,
|
||||||
|
$commerceHistoryContext
|
||||||
|
);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->agentLogger->warning('Shop search failed, continuing without shop results', [
|
$this->agentLogger->warning('Shop search failed, continuing without shop results', [
|
||||||
'userId' => $userId,
|
'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
|
private function limitKnowledgeChunks(array $knowledgeChunks, string $commerceIntent): array
|
||||||
{
|
{
|
||||||
return match ($commerceIntent) {
|
return match ($commerceIntent) {
|
||||||
@@ -361,7 +400,7 @@ final readonly class AgentRunner
|
|||||||
'err' => '<span class="text-danger">' . $msg . "</span>\n<hr>\n",
|
'err' => '<span class="text-danger">' . $msg . "</span>\n<hr>\n",
|
||||||
'think' => '<span class="text-info think">' . $msg . "</span>\n",
|
'think' => '<span class="text-info think">' . $msg . "</span>\n",
|
||||||
'info' => "\n\n<span class=\"text-info fw-bolder\">" . $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,
|
default => $msg,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,45 @@ namespace App\Agent;
|
|||||||
use App\Commerce\Dto\ShopProductResult;
|
use App\Commerce\Dto\ShopProductResult;
|
||||||
use App\Context\ContextService;
|
use App\Context\ContextService;
|
||||||
use App\Repository\SystemPromptRepository;
|
use App\Repository\SystemPromptRepository;
|
||||||
|
use App\Service\ModelGenerationConfigProvider;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
final readonly class PromptBuilder
|
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(
|
public function __construct(
|
||||||
private ContextService $contextService,
|
private ContextService $contextService,
|
||||||
private SystemPromptRepository $systemPromptRepository,
|
private SystemPromptRepository $systemPromptRepository,
|
||||||
|
private ModelGenerationConfigProvider $modelGenerationConfigProvider,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -26,129 +58,196 @@ final readonly class PromptBuilder
|
|||||||
* @param string $urlContent
|
* @param string $urlContent
|
||||||
* @param string[] $knowledgeChunks
|
* @param string[] $knowledgeChunks
|
||||||
* @param ShopProductResult[] $shopResults
|
* @param ShopProductResult[] $shopResults
|
||||||
* @param bool $fullContext
|
* @param bool|null $fullContext
|
||||||
|
* @param string|null $swagFullOutPut
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public function build(
|
public function build(
|
||||||
string $prompt,
|
string $prompt,
|
||||||
string $userId,
|
string $userId,
|
||||||
string $urlContent,
|
string $urlContent,
|
||||||
array $knowledgeChunks,
|
array $knowledgeChunks,
|
||||||
array $shopResults = [],
|
array $shopResults = [],
|
||||||
?bool $fullContext = false,
|
?bool $fullContext = false,
|
||||||
?string $swagFullOutPut = ''
|
?string $swagFullOutPut = ''
|
||||||
|
): string {
|
||||||
): string
|
|
||||||
{
|
|
||||||
$now = (new DateTimeImmutable())->format('Y-m-d H:i:s');
|
$now = (new DateTimeImmutable())->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// 1) SYSTEM INSTRUCTIONS
|
// 1) SYSTEM INSTRUCTIONS
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
$activePrompt = $this->systemPromptRepository->findActive();
|
$activePrompt = $this->systemPromptRepository->findActive();
|
||||||
|
|
||||||
if (!$activePrompt) {
|
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());
|
$activeSystemPrompt = str_replace('{% now %}', $now, $activePrompt->getContent());
|
||||||
|
|
||||||
$systemBlock = "SYSTEM:\n" . $activeSystemPrompt;
|
$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,
|
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 = '';
|
$blocks = array_filter([
|
||||||
$isDetailed = !(count($shopResults) > 5);
|
$systemBlock,
|
||||||
|
$shopBlock,
|
||||||
|
$knowledgeBlock,
|
||||||
|
$contextBlock,
|
||||||
|
$userBlock,
|
||||||
|
]);
|
||||||
|
|
||||||
if($swagFullOutPut){
|
return implode("\n\n", $blocks);
|
||||||
$shopBlock = "SHOP SEARCH QUERY: " . trim($swagFullOutPut)."\n";
|
}
|
||||||
$shopBlock .= "Source: Shop Search\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ($historyBudgetChars <= 0) {
|
||||||
if (!$product instanceof ShopProductResult) {
|
return '';
|
||||||
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 ($lines !== []) {
|
$history = $this->contextService->buildUserContextWithinBudget(
|
||||||
$shopBlock .=
|
userId: $userId,
|
||||||
"LIVE SHOP RESULTS (authoritative for products):\n" .
|
maxChars: $historyBudgetChars
|
||||||
implode("\n\n", $lines);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
if ($history === '') {
|
||||||
// 4) EXTERNAL KNOWLEDGE (SUPPORTING)
|
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 = [];
|
$knowledgeParts = [];
|
||||||
|
|
||||||
|
|
||||||
if ($knowledgeChunks !== []) {
|
if ($knowledgeChunks !== []) {
|
||||||
$lines = [];
|
$lines = [];
|
||||||
|
|
||||||
@@ -159,40 +258,59 @@ final readonly class PromptBuilder
|
|||||||
|
|
||||||
$knowledgeParts[] =
|
$knowledgeParts[] =
|
||||||
"RETRIEVED KNOWLEDGE (supporting):\n" .
|
"RETRIEVED KNOWLEDGE (supporting):\n" .
|
||||||
"Source: Documents \n".
|
"Source: Documents\n" .
|
||||||
implode("\n\n", $lines);
|
implode("\n\n", $lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($urlContent !== '') {
|
if ($urlContent !== '') {
|
||||||
$knowledgeParts[] =
|
$knowledgeParts[] =
|
||||||
"CONTENT FROM URL (supporting):\n" .
|
"CONTENT FROM URL (supporting):\n" .
|
||||||
"Source: URL \n".
|
"Source: URL\n" .
|
||||||
$urlContent;
|
$urlContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
$knowledgeBlock = '';
|
return implode("\n\n", $knowledgeParts);
|
||||||
if ($knowledgeParts !== []) {
|
}
|
||||||
$knowledgeBlock = implode("\n\n", $knowledgeParts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
/**
|
||||||
// 5) USER QUESTION
|
* Resolve how many characters may still be used by history.
|
||||||
// ------------------------------------------------------------
|
*
|
||||||
$userBlock =
|
* The active model num_ctx is converted into a conservative prompt budget.
|
||||||
"USER QUESTION:\n" .
|
* Shop, knowledge and user question are fixed priority blocks.
|
||||||
$prompt;
|
* History only receives the remaining space.
|
||||||
|
*/
|
||||||
|
private function resolveHistoryBudgetChars(string $fixedPrompt): int
|
||||||
|
{
|
||||||
|
$numCtx = $this->modelGenerationConfigProvider->getActiveNumCtx();
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
$outputReserveTokens = $this->clamp(
|
||||||
// 6) FINAL PROMPT ASSEMBLY
|
(int) floor($numCtx * self::OUTPUT_RESERVE_RATIO),
|
||||||
// ------------------------------------------------------------
|
self::OUTPUT_RESERVE_MIN_TOKENS,
|
||||||
$blocks = array_filter([
|
self::OUTPUT_RESERVE_MAX_TOKENS
|
||||||
$systemBlock,
|
);
|
||||||
$contextBlock,
|
|
||||||
$shopBlock,
|
|
||||||
$knowledgeBlock,
|
|
||||||
$userBlock,
|
|
||||||
]);
|
|
||||||
|
|
||||||
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
$normalizedPrompt = $this->normalize($originalPrompt);
|
||||||
[$priceMin, $priceMax] = $this->extractPriceRange($normalized);
|
|
||||||
$sizes = $this->extractSizes($normalized);
|
[$priceMin, $priceMax] = $this->extractPriceRange($normalizedPrompt);
|
||||||
$brand = $this->extractBrand($normalized);
|
$sizes = $this->extractSizes($normalizedPrompt);
|
||||||
$properties = [];
|
$brand = $this->extractBrand($normalizedPrompt);
|
||||||
|
|
||||||
$searchText = $this->buildSearchText(
|
$searchText = $this->buildSearchText(
|
||||||
$normalized,
|
$normalizedPrompt,
|
||||||
$sizes,
|
$sizes,
|
||||||
$brand,
|
$brand,
|
||||||
$priceMin,
|
$priceMin,
|
||||||
$priceMax
|
$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(
|
return new CommerceSearchQuery(
|
||||||
originalPrompt: $originalPrompt,
|
originalPrompt: $originalPrompt,
|
||||||
normalizedPrompt: $normalized,
|
normalizedPrompt: $normalizedPrompt,
|
||||||
searchText: $searchText !== '' ? $searchText : $normalized,
|
searchText: $finalSearchText,
|
||||||
brand: $brand,
|
brand: $brand,
|
||||||
sizes: $sizes,
|
sizes: $sizes,
|
||||||
properties: $properties,
|
properties: [],
|
||||||
priceMin: $priceMin,
|
priceMin: $priceMin,
|
||||||
priceMax: $priceMax,
|
priceMax: $priceMax,
|
||||||
intent: $intent,
|
intent: $intent,
|
||||||
@@ -138,9 +170,7 @@ final readonly class CommerceQueryParser
|
|||||||
{
|
{
|
||||||
$text = ' ' . $prompt . ' ';
|
$text = ' ' . $prompt . ' ';
|
||||||
|
|
||||||
$phrasesToRemove = $this->config->getPhrasesToRemove();
|
foreach ($this->config->getPhrasesToRemove() as $phrase) {
|
||||||
|
|
||||||
foreach ($phrasesToRemove as $phrase) {
|
|
||||||
$text = str_replace($phrase, ' ', $text);
|
$text = str_replace($phrase, ' ', $text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,16 +185,83 @@ final readonly class CommerceQueryParser
|
|||||||
if ($priceMin !== null || $priceMax !== null) {
|
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('/\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(?: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 = preg_replace('/\s+/u', ' ', $text) ?? $text;
|
||||||
$text = trim($text, " \t\n\r\0\x0B-.,");
|
$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));
|
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
|
private function toFloat(string $value): ?float
|
||||||
{
|
{
|
||||||
$value = str_replace(',', '.', trim($value));
|
$value = str_replace(',', '.', trim($value));
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
|||||||
final readonly class ShopSearchService
|
final readonly class ShopSearchService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private CommerceQueryParser $queryParser,
|
private CommerceQueryParser $queryParser,
|
||||||
private ShopwareCriteriaBuilder $criteriaBuilder,
|
private ShopwareCriteriaBuilder $criteriaBuilder,
|
||||||
private StoreApiClient $storeApiClient,
|
private StoreApiClient $storeApiClient,
|
||||||
private bool $enabled = true,
|
private bool $enabled = true,
|
||||||
private int $maxResults = 25,
|
private int $maxResults = 25,
|
||||||
private string $baseUrl
|
private string $baseUrl
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -28,21 +28,33 @@ final readonly class ShopSearchService
|
|||||||
/**
|
/**
|
||||||
* @return ShopProductResult[]
|
* @return ShopProductResult[]
|
||||||
*/
|
*/
|
||||||
public function search(string $originalPrompt, string $commerceIntent): array
|
public function search(
|
||||||
{
|
string $originalPrompt,
|
||||||
|
string $commerceIntent,
|
||||||
|
string $commerceHistoryContext = ''
|
||||||
|
): array {
|
||||||
if (!$this->enabled) {
|
if (!$this->enabled) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = [];
|
$response = [];
|
||||||
|
|
||||||
$query = $this->queryParser->parse($originalPrompt, $commerceIntent);
|
$query = $this->queryParser->parse(
|
||||||
|
$originalPrompt,
|
||||||
|
$commerceIntent,
|
||||||
|
$commerceHistoryContext
|
||||||
|
);
|
||||||
|
|
||||||
$criteria = $this->criteriaBuilder->build($query, $this->maxResults);
|
$criteria = $this->criteriaBuilder->build($query, $this->maxResults);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->storeApiClient->searchProducts($criteria);
|
$response = $this->storeApiClient->searchProducts($criteria);
|
||||||
} catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $e) {
|
} catch (
|
||||||
|
ClientExceptionInterface
|
||||||
|
| RedirectionExceptionInterface
|
||||||
|
| ServerExceptionInterface
|
||||||
|
| TransportExceptionInterface $e
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->mapProducts($response);
|
return $this->mapProducts($response);
|
||||||
@@ -66,12 +78,12 @@ final readonly class ShopSearchService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$results[] = new ShopProductResult(
|
$results[] = new ShopProductResult(
|
||||||
id: (string)($row['id'] ?? ''),
|
id: (string) ($row['id'] ?? ''),
|
||||||
name: trim((string)($row['translated']['name'] ?? '')),
|
name: trim((string) ($row['translated']['name'] ?? '')),
|
||||||
productNumber: isset($row['productNumber']) ? (string)$row['productNumber'] : null,
|
productNumber: isset($row['productNumber']) ? (string) $row['productNumber'] : null,
|
||||||
manufacturer: $this->extractManufacturer($row),
|
manufacturer: $this->extractManufacturer($row),
|
||||||
price: $this->extractPrice($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),
|
url: $this->baseUrl . $this->extractUrl($row),
|
||||||
highlights: $this->extractHighlights($row),
|
highlights: $this->extractHighlights($row),
|
||||||
description: $this->cleanUpDescription($row),
|
description: $this->cleanUpDescription($row),
|
||||||
@@ -98,11 +110,11 @@ final readonly class ShopSearchService
|
|||||||
private function cleanUpDescription(array $description): string
|
private function cleanUpDescription(array $description): string
|
||||||
{
|
{
|
||||||
if (isset($description['translated']['description'])) {
|
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 = html_entity_decode($newDesc);
|
||||||
$newDesc = preg_replace('/^[ \t]*\R/m', '', $newDesc);
|
$newDesc = preg_replace('/^[ \t]*\R/m', '', $newDesc);
|
||||||
$newDesc = preg_replace('/[ \t]{2,}/', ' ', $newDesc);
|
$newDesc = preg_replace('/[ \t]{2,}/', ' ', $newDesc);
|
||||||
$result = trim((string)$newDesc);
|
$result = trim((string) $newDesc);
|
||||||
|
|
||||||
return mb_substr($result, 0, 1500);
|
return mb_substr($result, 0, 1500);
|
||||||
}
|
}
|
||||||
@@ -142,7 +154,7 @@ final readonly class ShopSearchService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return number_format((float)$unitPrice, 2, ',', '.') . ' €';
|
return number_format((float) $unitPrice, 2, ',', '.') . ' €';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function extractUrl(array $row): ?string
|
private function extractUrl(array $row): ?string
|
||||||
@@ -175,7 +187,7 @@ final readonly class ShopSearchService
|
|||||||
$highlights = [];
|
$highlights = [];
|
||||||
|
|
||||||
if (isset($row['available'])) {
|
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']) !== '') {
|
if (isset($row['productNumber']) && is_string($row['productNumber']) && trim($row['productNumber']) !== '') {
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Config;
|
namespace App\Config;
|
||||||
|
|
||||||
class AgentRunnerConfig
|
final class AgentRunnerConfig
|
||||||
{
|
{
|
||||||
public function getShopPrompt($prompt): string
|
public function getShopPrompt(string $prompt, string $commerceHistoryContext = ''): string
|
||||||
{
|
{
|
||||||
/**
|
$historyBlock = '';
|
||||||
* 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
|
|
||||||
*/
|
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 '
|
return '
|
||||||
Generate a short search query for Shopware 6 from the following user input text.
|
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).
|
- 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.
|
- Separate terms using spaces only.
|
||||||
- If a relevant product name is present, it must be placed at the beginning of the final search query.
|
- 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:
|
Output format:
|
||||||
Keyword1 Keyword2 Keyword3
|
Keyword1 Keyword2 Keyword3
|
||||||
|
' . $historyBlock . '
|
||||||
input text: ' . $prompt . '
|
|
||||||
|
CURRENT USER INPUT:
|
||||||
|
' . $prompt . '
|
||||||
';
|
';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,8 @@ class CommerceQueryParserConfig
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPhrasesToRemove(): array{
|
public function getPhrasesToRemove(): array
|
||||||
|
{
|
||||||
return [
|
return [
|
||||||
'ich suche',
|
'ich suche',
|
||||||
'suche',
|
'suche',
|
||||||
@@ -29,4 +30,34 @@ class CommerceQueryParserConfig
|
|||||||
'alternativen',
|
'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',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -72,12 +72,69 @@ final class ContextService
|
|||||||
return '';
|
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);
|
$selected = array_slice($lines, -$maxLines);
|
||||||
|
|
||||||
return implode("\n", $selected);
|
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.
|
* 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.
|
* Resolves the absolute history file path for a user.
|
||||||
*/
|
*/
|
||||||
@@ -114,4 +247,4 @@ final class ContextService
|
|||||||
|
|
||||||
return $this->historyDir . '/' . $safeUserId . '.txt';
|
return $this->historyDir . '/' . $safeUserId . '.txt';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
@@ -8,7 +7,7 @@ namespace App\Service;
|
|||||||
use App\Entity\ModelGenerationConfig;
|
use App\Entity\ModelGenerationConfig;
|
||||||
use App\Repository\ModelGenerationConfigRepository;
|
use App\Repository\ModelGenerationConfigRepository;
|
||||||
|
|
||||||
final class ModelGenerationConfigProvider
|
final readonly class ModelGenerationConfigProvider
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ModelGenerationConfigRepository $repository
|
private ModelGenerationConfigRepository $repository
|
||||||
@@ -39,4 +38,11 @@ final class ModelGenerationConfigProvider
|
|||||||
active: false
|
active: false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public function getActiveNumCtx(): int
|
||||||
|
{
|
||||||
|
$numCtx = (int) $this->getActiveForModel()->getNumCtx();
|
||||||
|
|
||||||
|
return max(512, $numCtx);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user