move intent an config value into config files

This commit is contained in:
team2
2026-04-23 21:49:54 +02:00
parent 87417febf4
commit fce44e971d
17 changed files with 1937 additions and 1133 deletions

View File

@@ -4,9 +4,6 @@ declare(strict_types=1);
namespace App\Agent;
use App\Commerce\CommerceReferenceResolver;
use App\Commerce\CommerceReferenceStore;
use App\Commerce\Dto\CommerceReferenceContext;
use App\Commerce\SearchRepairService;
use App\Commerce\ShopSearchService;
use App\Config\AgentRunnerConfig;
@@ -21,8 +18,6 @@ use Throwable;
final readonly class AgentRunner
{
private const COMMERCE_HISTORY_BUDGET_CHARS = 1000;
private bool $systemMsgOn;
public function __construct(
@@ -33,8 +28,6 @@ final readonly class AgentRunner
private RetrieverInterface $retriever,
private ShopSearchService $shopSearchService,
private SearchRepairService $searchRepairService,
private CommerceReferenceStore $commerceReferenceStore,
private CommerceReferenceResolver $commerceReferenceResolver,
private CommerceIntentLite $commerceIntentLite,
private OllamaClient $ollamaClient,
private LoggerInterface $agentLogger,
@@ -51,14 +44,13 @@ final readonly class AgentRunner
$prompt = trim($prompt);
if ($prompt === '') {
yield $this->systemMsg('❌ Empty prompt.', 'err');
yield $this->systemMsg($this->agentRunnerConfig->getEmptyPromptMessage(), 'err');
return;
}
$shopResults = [];
$primaryShopResults = [];
$factSources = [];
$contextSignals = [];
$sources = [];
$optimizedShopQuery = '';
$shopSearchQuery = '';
$commerceIntent = CommerceIntentLite::NONE;
@@ -66,8 +58,6 @@ final readonly class AgentRunner
$attemptedShopRepair = false;
$usedShopRepair = false;
$shopRepairQueries = [];
$activeCommerceReference = null;
$shopChecked = false;
$this->agentLogger->info('Agent run started', [
'userId' => $userId,
@@ -79,74 +69,39 @@ final readonly class AgentRunner
// Additional context strategies can be added here later.
}
yield $this->systemMsg('Ich analysiere deine Anfrage...', 'think');
yield $this->systemMsg('Ich prüfe auf Internetquellen...', 'think');
yield $this->systemMsg($this->agentRunnerConfig->getAnalyzeRequestMessage(), 'think');
yield $this->systemMsg($this->agentRunnerConfig->getCheckInternetSourcesMessage(), 'think');
$urlContent = $this->urlAnalyzer->extractContentFromPrompt($prompt);
if ($urlContent !== '') {
$this->addBadge($factSources, 'Externe URL');
$this->addSource($sources, $this->agentRunnerConfig->getExternalUrlSourceLabel());
}
yield $this->systemMsg('Ich hole relevante Daten aus meinem RAG-Wissen...', 'think');
yield $this->systemMsg($this->agentRunnerConfig->getRetrieveKnowledgeMessage(), 'think');
$knowledgeChunks = $this->retriever->retrieve($prompt);
if ($knowledgeChunks !== []) {
$this->addBadge($factSources, 'RAG Wissen');
$this->addSource($sources, $this->agentRunnerConfig->getRagKnowledgeSourceLabel());
}
$commerceIntent = $this->detectCommerceIntent($prompt);
if ($this->isCommerceIntent($commerceIntent)) {
yield $this->systemMsg('Ich optimiere die Recherche...', 'think');
yield $this->systemMsg($this->agentRunnerConfig->getOptimizeSearchMessage(), 'think');
$commerceHistoryContext = $this->buildCommerceHistoryContext($userId);
$activeCommerceReference = $this->loadCommerceReference($userId);
if ($commerceHistoryContext !== '') {
$this->addBadge($contextSignals, 'Gesprächskontext');
$this->addSource($sources, $this->agentRunnerConfig->getConversationHistorySourceLabel());
}
if ($activeCommerceReference !== null) {
$this->addBadge($contextSignals, 'Commerce-Referenz');
}
$isReferenceOnlyFollowUp = $this->isReferenceOnlyCommerceFollowUp(
$optimizedShopQuery = $this->buildOptimizedShopQuery(
$prompt,
$activeCommerceReference
$userId,
$commerceHistoryContext
);
if ($isReferenceOnlyFollowUp) {
$shopSearchQuery = $this->buildDeterministicReferenceShopQuery($activeCommerceReference);
if ($shopSearchQuery !== '') {
$this->addBadge($contextSignals, 'Deterministische Referenzsuche');
}
$this->agentLogger->info('Using deterministic reference shop query', [
'userId' => $userId,
'commerceIntent' => $commerceIntent,
'prompt' => $prompt,
'shopSearchQuery' => $shopSearchQuery,
'referenceProductName' => $activeCommerceReference?->productName,
'referenceFocusTerms' => $activeCommerceReference?->focusTerms,
]);
} else {
$optimizedShopQuery = $this->buildOptimizedShopQuery(
$prompt,
$userId,
$commerceHistoryContext
);
if ($optimizedShopQuery !== '' && $optimizedShopQuery !== $prompt) {
$this->addBadge($contextSignals, 'Query-Optimierung');
}
$shopSearchQuery = $optimizedShopQuery !== '' ? $optimizedShopQuery : $prompt;
}
if ($shopSearchQuery === '') {
$shopSearchQuery = $prompt;
}
$shopSearchQuery = $optimizedShopQuery !== '' ? $optimizedShopQuery : $prompt;
$this->agentLogger->info('Commerce search prepared', [
'userId' => $userId,
@@ -154,26 +109,20 @@ final readonly class AgentRunner
'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
'optimizedShopQuery' => $optimizedShopQuery,
'shopSearchQuery' => $shopSearchQuery,
'usedDeterministicReferenceQuery' => $isReferenceOnlyFollowUp,
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
'hasActiveCommerceReference' => $activeCommerceReference !== null,
'activeCommerceReferenceProduct' => $activeCommerceReference?->productName,
]);
yield $this->systemMsg(
'Ich rufe Recherchedaten ab (type: ' . $commerceIntent . ')',
sprintf($this->agentRunnerConfig->getFetchSearchDataMessageTemplate(), $commerceIntent),
'think'
);
$shopChecked = true;
$primaryShopResults = $this->searchShop(
$shopSearchQuery,
$commerceIntent,
$userId,
$commerceHistoryContext,
$activeCommerceReference
$commerceHistoryContext
);
$repairPayload = $this->repairShopResults(
@@ -192,13 +141,11 @@ final readonly class AgentRunner
$shopRepairQueries = $repairPayload['repairQueries'];
if ($shopResults !== []) {
$this->addBadge($factSources, 'Shopsystem');
} elseif ($shopChecked) {
$this->addBadge($factSources, 'Shopsystem geprüft');
$this->addSource($sources, $this->agentRunnerConfig->getShopSystemSourceLabel());
}
if ($attemptedShopRepair) {
$this->addBadge($contextSignals, 'Erweiterte Shopsuche');
$this->addSource($sources, $this->agentRunnerConfig->getExtendedShopSearchSourceLabel());
}
}
@@ -206,7 +153,7 @@ final readonly class AgentRunner
$knowledgeChunks = $this->limitKnowledgeChunks($knowledgeChunks, $commerceIntent);
}
yield $this->systemMsg('Ich analysiere alle Informationen...', 'think');
yield $this->systemMsg($this->agentRunnerConfig->getAnalyzeAllInformationMessage(), 'think');
$finalPrompt = $this->promptBuilder->build(
prompt: $prompt,
@@ -226,7 +173,6 @@ final readonly class AgentRunner
'shopSearchQuery' => $shopSearchQuery,
'primaryShopResultsCount' => count($primaryShopResults),
'shopResultsCount' => count($shopResults),
'shopChecked' => $shopChecked,
'attemptedShopRepair' => $attemptedShopRepair,
'usedShopRepair' => $usedShopRepair,
'shopRepairQueries' => $shopRepairQueries,
@@ -243,21 +189,19 @@ final readonly class AgentRunner
]);
}
if ($factSources !== [] || $contextSignals !== []) {
yield $this->emitSourceSummary(
$factSources,
$contextSignals,
'Genutzte Datenpfade'
if ($sources !== []) {
yield $this->emitSources(
$sources,
$this->agentRunnerConfig->getUsedSourcesPrefix()
);
}
$fullOutput = yield from $this->streamFinalAnswer($finalPrompt);
if ($factSources !== [] || $contextSignals !== []) {
yield $this->emitSourceSummary(
$factSources,
$contextSignals,
'Quellen und Signale'
if ($sources !== []) {
yield $this->emitSources(
$sources,
$this->agentRunnerConfig->getSourcesPrefix()
);
}
@@ -266,11 +210,10 @@ final readonly class AgentRunner
}
if ($fullOutput !== '') {
$this->persistConversationState(
userId: $userId,
prompt: $prompt,
fullOutput: $fullOutput,
shopResults: $shopResults
$this->contextService->appendHistory(
$userId,
$prompt,
$fullOutput
);
}
@@ -281,7 +224,6 @@ final readonly class AgentRunner
'commerceIntent' => $commerceIntent,
'primaryShopResultsCount' => count($primaryShopResults),
'shopResultsCount' => count($shopResults),
'shopChecked' => $shopChecked,
'attemptedShopRepair' => $attemptedShopRepair,
'usedShopRepair' => $usedShopRepair,
'shopRepairQueries' => $shopRepairQueries,
@@ -292,8 +234,6 @@ final readonly class AgentRunner
'shopSearchQuery' => $shopSearchQuery,
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
'hasActiveCommerceReference' => $activeCommerceReference !== null,
'activeCommerceReferenceProduct' => $activeCommerceReference?->productName,
]);
} catch (Throwable $e) {
$this->agentLogger->error('Agent run failed', [
@@ -361,42 +301,6 @@ final readonly class AgentRunner
return $this->sanitizeOptimizedShopQuery($optimizedQuery);
}
private function isReferenceOnlyCommerceFollowUp(
string $prompt,
?CommerceReferenceContext $referenceContext
): bool {
if ($referenceContext === null) {
return false;
}
$normalizedPrompt = mb_strtolower(trim($prompt), 'UTF-8');
$normalizedPrompt = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $normalizedPrompt) ?? $normalizedPrompt;
$normalizedPrompt = preg_replace('/\s+/u', ' ', $normalizedPrompt) ?? $normalizedPrompt;
$normalizedPrompt = trim($normalizedPrompt);
if ($normalizedPrompt === '') {
return false;
}
if (preg_match('/\b(testomat|lab|evo|eco|calc|thcl|808|2000)\b/u', $normalizedPrompt) === 1) {
return false;
}
return preg_match(
'/\b(preis|preise|kosten|kostet|dazu|dafuer|dafür|davon|was kostet das|verfuegbarkeit|verfügbarkeit|shop|link)\b/u',
$normalizedPrompt
) === 1;
}
private function buildDeterministicReferenceShopQuery(?CommerceReferenceContext $referenceContext): string
{
if ($referenceContext === null) {
return '';
}
return trim($referenceContext->buildReferenceSearchText());
}
/**
* @return array{
* results: array,
@@ -445,15 +349,13 @@ final readonly class AgentRunner
string $query,
string $commerceIntent,
string $userId,
string $commerceHistoryContext = '',
?CommerceReferenceContext $referenceContext = null
string $commerceHistoryContext = ''
): array {
try {
return $this->shopSearchService->search(
$query,
$commerceIntent,
$commerceHistoryContext,
$referenceContext
$commerceHistoryContext
);
} catch (Throwable $e) {
$this->agentLogger->warning('Shop search failed, continuing without shop results', [
@@ -462,8 +364,6 @@ final readonly class AgentRunner
'query' => $query,
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
'hasReferenceContext' => $referenceContext !== null,
'referenceProductName' => $referenceContext?->productName,
'exception' => $e,
]);
@@ -475,73 +375,23 @@ final readonly class AgentRunner
{
return $this->contextService->buildUserContextWithinBudget(
$userId,
self::COMMERCE_HISTORY_BUDGET_CHARS
);
}
private function loadCommerceReference(string $userId): ?CommerceReferenceContext
{
try {
return $this->commerceReferenceStore->load($userId);
} catch (Throwable $e) {
$this->agentLogger->warning('Failed to load commerce reference context', [
'userId' => $userId,
'exception' => $e,
]);
return null;
}
}
/**
* @param array<int, mixed> $shopResults
*/
private function storeCommerceReference(string $userId, string $prompt, string $answer, array $shopResults): void
{
try {
$referenceContext = $this->commerceReferenceResolver->resolveFromCommerceTurn(
$prompt,
$answer,
$shopResults
);
if ($referenceContext === null) {
return;
}
$this->commerceReferenceStore->save($userId, $referenceContext);
} catch (Throwable $e) {
$this->agentLogger->warning('Failed to persist commerce reference context', [
'userId' => $userId,
'exception' => $e,
]);
}
}
/**
* @param array<int, mixed> $shopResults
*/
private function persistConversationState(
string $userId,
string $prompt,
string $fullOutput,
array $shopResults
): void {
$this->contextService->appendHistory($userId, $prompt, $fullOutput);
$this->storeCommerceReference(
userId: $userId,
prompt: $prompt,
answer: $fullOutput,
shopResults: $shopResults
$this->agentRunnerConfig->getCommerceHistoryBudgetChars()
);
}
private function limitKnowledgeChunks(array $knowledgeChunks, string $commerceIntent): array
{
return match ($commerceIntent) {
CommerceIntentLite::PRODUCT_SEARCH => array_slice($knowledgeChunks, 0, 2),
CommerceIntentLite::ADVISORY_PRODUCT_SEARCH => array_slice($knowledgeChunks, 0, 3),
CommerceIntentLite::PRODUCT_SEARCH => array_slice(
$knowledgeChunks,
0,
$this->agentRunnerConfig->getProductSearchKnowledgeChunkLimit()
),
CommerceIntentLite::ADVISORY_PRODUCT_SEARCH => array_slice(
$knowledgeChunks,
0,
$this->agentRunnerConfig->getAdvisoryProductSearchKnowledgeChunkLimit()
),
default => $knowledgeChunks,
};
}
@@ -555,8 +405,8 @@ final readonly class AgentRunner
}
$query = preg_split('/\R+/u', $query, 2)[0] ?? $query;
$query = preg_replace('/^(?:keywords?|suchquery|search\s*query|query)\s*:\s*/iu', '', $query) ?? $query;
$query = trim($query, " \t\n\r\0\x0B\"'`");
$query = preg_replace($this->agentRunnerConfig->getOptimizedShopQueryPrefixPattern(), '', $query) ?? $query;
$query = trim($query, $this->agentRunnerConfig->getOptimizedShopQueryTrimCharacters());
$query = preg_replace('/\s+/u', ' ', $query) ?? $query;
return trim($query);
@@ -582,7 +432,7 @@ final readonly class AgentRunner
if ($cleanToken === '') {
if ($firstThinkLoop) {
yield $this->systemMsg('Denke nach...', 'think');
yield $this->systemMsg($this->agentRunnerConfig->getThinkingWhileStreamingMessage(), 'think');
$firstThinkLoop = false;
}
@@ -601,60 +451,46 @@ final readonly class AgentRunner
if ($finalChunk !== null) {
yield $this->systemMsg($finalChunk, 'answer');
} elseif ($fullOutput === '') {
yield $this->systemMsg('❌ Es wurden keine Daten vom LLM empfangen.', 'err');
yield $this->systemMsg($this->agentRunnerConfig->getNoLlmDataReceivedMessage(), 'err');
}
return $fullOutput;
}
/**
* @param string[] $factSources
* @param string[] $contextSignals
* @param string[] $sources
*/
private function emitSourceSummary(array $factSources, array $contextSignals, string $label): string
private function emitSources(array $sources, string $prefix): string
{
$parts = [];
if ($factSources !== []) {
$parts[] = 'Fakten: ' . implode(' ', $factSources);
}
if ($contextSignals !== []) {
$parts[] = 'Kontext: ' . implode(' ', $contextSignals);
}
return $this->systemMsg(
$label . ': ' . implode(' &nbsp;&nbsp; ', $parts),
'info'
);
return $this->systemMsg($prefix . implode(' ', $sources), 'info');
}
/**
* @param string[] $target
* @param string[] $sources
*/
private function addBadge(array &$target, string $label): void
private function addSource(array &$sources, string $label): void
{
$badge = $this->badge($label);
if (!in_array($badge, $target, true)) {
$target[] = $badge;
if (!in_array($badge, $sources, true)) {
$sources[] = $badge;
}
}
private function buildUserErrorMessage(Throwable $e): string
{
if (!$this->debug) {
return '❌ Bei der Verarbeitung der Anfrage ist ein interner Fehler aufgetreten.';
return $this->agentRunnerConfig->getGenericInternalErrorMessage();
}
return '❌ Interner Fehler: '
return $this->agentRunnerConfig->getDebugInternalErrorPrefix()
. htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
private function badge(string $label): string
{
return sprintf(
'<span class="badge bg-info text-black">%s</span>',
$this->agentRunnerConfig->getSourceBadgeHtmlTemplate(),
htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
);
}
@@ -667,10 +503,13 @@ final readonly class AgentRunner
return match ($type) {
'answer' => $msg,
'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>" . htmlspecialchars($msg, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</code>\n",
'err' => sprintf($this->agentRunnerConfig->getErrorHtmlTemplate(), $msg),
'think' => sprintf($this->agentRunnerConfig->getThinkHtmlTemplate(), $msg),
'info' => sprintf($this->agentRunnerConfig->getInfoHtmlTemplate(), $msg),
'debug' => sprintf(
$this->agentRunnerConfig->getDebugHtmlTemplate(),
htmlspecialchars($msg, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
),
default => $msg,
};
}

View File

@@ -18,6 +18,7 @@ final readonly class PromptBuilder
private ContextService $contextService,
private SystemPromptRepository $systemPromptRepository,
private ModelGenerationConfigProvider $modelGenerationConfigProvider,
private PromptBuilderConfig $config,
) {
}
@@ -31,7 +32,6 @@ final readonly class PromptBuilder
* @param ShopProductResult[] $shopResults
* @param bool|null $fullContext
* @param string|null $swagFullOutPut
* @return string
*/
public function build(
string $prompt,
@@ -48,23 +48,21 @@ final readonly class PromptBuilder
$hasShopResults = $shopResults !== [];
$isTechnicalProductQuestion = $this->isLikelyTechnicalProductQuestion($prompt);
$isPriceDrivenQuestion = $this->isLikelyPriceDrivenQuestion($prompt);
$asksForAccessoryOrBundle = $this->asksForAccessoryOrBundle($prompt);
$systemBlock = $this->buildSystemBlock();
$shopBlock = $this->buildShopBlock($shopResults, $swagFullOutPut);
$outputPriorityBlock = $this->buildOutputPriorityBlock($hasShopResults, $isPriceDrivenQuestion);
$outputPriorityBlock = $this->buildOutputPriorityBlock($hasShopResults);
$responseFormatBlock = $this->buildResponseFormatBlock(
$prompt,
$hasShopResults,
$isTechnicalProductQuestion,
$isPriceDrivenQuestion
hasShopResults: $hasShopResults,
isTechnicalProductQuestion: $isTechnicalProductQuestion,
asksForAccessoryOrBundle: $asksForAccessoryOrBundle
);
$knowledgeBlock = $this->buildKnowledgeBlock(
$knowledgeChunks,
$urlContent,
$prompt,
$hasShopResults,
$isPriceDrivenQuestion
knowledgeChunks: $knowledgeChunks,
urlContent: $urlContent,
hasShopResults: $hasShopResults,
isTechnicalProductQuestion: $isTechnicalProductQuestion
);
$userBlock = $this->buildUserBlock($prompt);
@@ -106,12 +104,12 @@ final readonly class PromptBuilder
$activeSystemPrompt = str_replace('{% now %}', $now, $activePrompt->getContent());
return "SYSTEM:\n" . $this->normalizeBlockText($activeSystemPrompt);
return $this->config->getSystemSectionLabel() . ":\n" . $this->normalizeBlockText($activeSystemPrompt);
}
private function buildUserBlock(string $prompt): string
{
return "USER QUESTION:\n" . $prompt;
return $this->config->getUserQuestionSectionLabel() . ":\n" . $prompt;
}
/**
@@ -146,12 +144,11 @@ final readonly class PromptBuilder
return '';
}
return
"CONVERSATION CONTEXT (contextual only):\n" .
"The following messages are previous turns of this conversation.\n" .
"Use them to resolve references, follow-up questions, and user intent.\n" .
"They must not override retrieved factual knowledge or live shop data.\n\n" .
$history;
return $this->implodeBlocks([
$this->config->getConversationContextSectionLabel() . ':',
$this->implodeLines($this->config->getConversationContextIntroLines()),
$history,
]);
}
/**
@@ -165,10 +162,11 @@ final readonly class PromptBuilder
$parts = [];
if ($swagFullOutPut !== null && $swagFullOutPut !== '') {
$parts[] =
"SHOP SEARCH QUERY:\n" .
$swagFullOutPut . "\n" .
"Source: Shop Search";
$parts[] = $this->implodeBlocks([
$this->config->getShopSearchQuerySectionLabel() . ':',
$swagFullOutPut,
$this->config->getShopSearchQuerySourceLine(),
]);
}
$normalizedShopResults = array_values(array_filter(
@@ -181,77 +179,33 @@ final readonly class PromptBuilder
}
$totalCount = count($normalizedShopResults);
$limitedShopResults = array_slice($normalizedShopResults, 0, PromptBuilderConfig::MAX_SHOP_RESULTS_IN_PROMPT);
$isDetailed = count($limitedShopResults) <= 5;
$limitedShopResults = array_slice($normalizedShopResults, 0, $this->config->getMaxShopResultsInPrompt());
$isDetailed = count($limitedShopResults) <= $this->config->getDetailedShopResultsMaxCount();
$lines = [];
foreach ($limitedShopResults as $i => $product) {
$n = $i + 1;
$entryParts = [
"[{$n}] " . $this->normalizeBlockText($product->name),
];
if ($product->productNumber) {
$entryParts[] = "Product number: " . $this->normalizeBlockText($product->productNumber);
}
if ($product->manufacturer) {
$entryParts[] = "Manufacturer: " . $this->normalizeBlockText($product->manufacturer);
}
if ($product->price) {
$entryParts[] = "Price: " . $this->normalizeBlockText($product->price);
}
if ($product->available !== null) {
$entryParts[] = "Available: " . ($product->available ? 'yes' : 'no');
}
foreach ($product->highlights as $highlight) {
$highlight = $this->normalizeBlockText((string) $highlight);
if ($highlight !== '') {
$entryParts[] = "- " . $highlight;
}
}
if ($product->url) {
$entryParts[] = "URL: " . $this->normalizeBlockText($product->url);
}
if ($product->productImage) {
$entryParts[] = "Product image: " . $this->normalizeBlockText($product->productImage);
}
if ($isDetailed && $product->description) {
$entryParts[] = "Description: " . $this->normalizeBlockText($product->description);
}
if ($product->customFields) {
$entryParts[] = "Meta information: " . $this->normalizeBlockText($product->customFields);
}
$lines[] = implode("\n", $entryParts);
$lines[] = $this->buildShopProductEntry(
product: $product,
index: $i + 1,
isDetailed: $isDetailed
);
}
if ($lines !== []) {
$header =
"LIVE SHOP RESULTS (authoritative for current commercial details):\n" .
"Use these results as the primary source for current price, availability, URL, and current shop-visible product naming.\n" .
"If retrieved documents conflict with shop data on price, availability, URL, or current naming, prefer the shop data.\n" .
"Output real URL values exactly as provided in the shop results. Do not replace them with placeholders, link labels, or product names.\n" .
"Do not infer undocumented technical specifications from shop data.\n" .
"Commercial fields from shop data may only be assigned to a product if the shop item clearly matches the same product identity.\n" .
"Do not merge a device identified in retrieved knowledge with price, URL, product number, or availability from a different shop item such as a reagent, accessory, kit, consumable, or service item.\n" .
"If shop results only contain accessories, reagents, indicators, or consumables, do not conclude that no matching main device exists unless the sources explicitly support that conclusion.\n" .
"If the user asks for price filtering, use the numeric prices in these live shop results as the decisive source for filtering.";
$headerLines = $this->config->getLiveShopResultsHeaderLines();
if ($totalCount > count($limitedShopResults)) {
$header .= "\n" .
"Only the top " . count($limitedShopResults) . " ranked shop results are shown here out of {$totalCount} total results.";
$headerLines[] = sprintf(
$this->config->getLiveShopResultsOverflowNoticeTemplate(),
count($limitedShopResults),
$totalCount
);
}
$parts[] = $header . "\n\n" . implode("\n\n", $lines);
$parts[] = $this->implodeBlocks([
$this->implodeLines($headerLines),
implode("\n\n", $lines),
]);
}
return $this->implodeBlocks($parts);
@@ -260,89 +214,60 @@ final readonly class PromptBuilder
/**
* Build a small priority block that tells the model what to surface first.
*/
private function buildOutputPriorityBlock(bool $hasShopResults, bool $isPriceDrivenQuestion): string
private function buildOutputPriorityBlock(bool $hasShopResults): string
{
if (!$hasShopResults) {
return '';
}
if ($isPriceDrivenQuestion) {
return
"OUTPUT PRIORITY:\n" .
"For price-driven questions, evaluate shop results first for numeric price filtering.\n" .
"Use retrieved knowledge afterwards only to add technical context or explain missing commercial coverage.\n" .
"Do not let accessory-only shop results prove that no matching device exists unless the sources explicitly support that conclusion.\n";
}
return
"OUTPUT PRIORITY:\n" .
"Use retrieved knowledge first to determine the technically matching product or answer.\n" .
"If shop results are present, use them afterwards to add current price, availability, and the actual URL.\n" .
"Do not let bundles, accessories, or service items override a better technical match unless the user explicitly asks for them.\n";
return $this->buildRuleBlock(
$this->config->getOutputPrioritySectionLabel(),
$this->config->getOutputPriorityRules()
);
}
private function buildResponseFormatBlock(
string $prompt,
bool $hasShopResults,
bool $isTechnicalProductQuestion,
bool $isPriceDrivenQuestion
bool $asksForAccessoryOrBundle
): string {
$rules = [
"RESPONSE FORMAT RULES:",
"- Keep normal spacing between all words. Never fuse words together.",
"- Use short, clean paragraphs or short labeled sections.",
"- Do not use persuasive or promotional wording.",
"- Do not repeat the same fact in slightly different wording.",
"- Never mention brands, manufacturers, model names, or product families that do not appear in the provided shop results, retrieved knowledge, URL content, or conversation context.",
"- If no suitable product is explicitly grounded in the provided sources, say that plainly instead of inventing alternatives.",
"- Do not generate external alternative lists, vendor suggestions, or purchase recommendations unless they are explicitly present in the provided sources.",
"- Do not combine technical identity from one source with commercial fields from a different product.",
"- Product number, price, availability, and URL must belong to the same explicitly grounded product.",
];
$rules = $this->config->getResponseFormatBaseRules();
if ($hasShopResults) {
$rules[] = "- If a product is identified, prefer this structure per product: product name, product number, price, availability, URL, then only the most relevant technical facts.";
$rules[] = "- Keep price, availability, and URL on separate lines when they are present.";
$rules[] = "- Only use shop price, URL, product number, or availability for the main product when the shop result clearly matches that same main product.";
$rules[] = "- If the matching shop item appears to be an accessory, reagent, consumable, set, or kit, keep it separate and do not present its commercial fields as the main device.";
$rules[] = "- If the commercial match is uncertain, say that commercial details for the main product are not clearly available in the provided shop results.";
$rules[] = "- If the question includes a price threshold, filter using only explicit numeric shop prices.";
$rules[] = "- Do not say that no device exists above a threshold merely because only cheaper accessories were found in the shop results.";
$rules = array_merge($rules, $this->config->getResponseFormatWithShopRules());
} else {
$rules[] = "- If no shop results are present, do not compensate by inventing external products or external manufacturers.";
$rules = array_merge($rules, $this->config->getResponseFormatWithoutShopRules());
}
if ($isTechnicalProductQuestion) {
$rules[] = "- Write like technical documentation: precise, neutral, and source-close.";
$rules[] = "- Prefer exact values, ranges, thresholds, compatibility notes, and application areas over general explanation.";
$rules[] = "- If the sources only support a negative finding, output only that negative finding and do not add speculative alternatives.";
$rules = array_merge($rules, $this->config->getResponseFormatTechnicalRules());
}
if ($isPriceDrivenQuestion) {
$rules[] = "- For price-driven questions, answer the threshold result first.";
$rules[] = "- If no grounded shop product fulfills the threshold, say that clearly.";
$rules[] = "- Then optionally explain whether retrieved knowledge mentions relevant devices that are not commercially listed in the current shop results.";
if ($asksForAccessoryOrBundle) {
$rules = array_merge($rules, $this->config->getResponseFormatAccessoryRules());
}
if ($this->asksForAccessoryOrBundle($prompt)) {
$rules[] = "- If the user asks for a matching accessory, separate the answer into: main device and matching accessory.";
$rules[] = "- The main device must come first. The accessory must not replace the main device.";
$rules[] = "- Only name an accessory as matching if compatibility is explicitly grounded in the provided sources.";
$rules[] = "- Do not call accessories, indicators, reagents, kits, sets, or consumables a device, measuring device, or main product unless the source explicitly says so.";
}
return implode("\n", $rules);
return $this->buildRuleBlock(
$this->config->getResponseFormatSectionLabel(),
$rules
);
}
/**
* Build the knowledge block.
*
* Retrieved knowledge remains the main source for technical matching and explanation.
* Shop data is preferred for current commercial fields.
*
* @param string[] $knowledgeChunks
*/
private function buildKnowledgeBlock(
array $knowledgeChunks,
string $urlContent,
string $prompt,
bool $hasShopResults,
bool $isPriceDrivenQuestion
bool $isTechnicalProductQuestion
): string {
$knowledgeParts = [];
$isTechnicalProductQuestion = $this->isLikelyTechnicalProductQuestion($prompt);
if ($knowledgeChunks !== []) {
$lines = [];
@@ -359,56 +284,71 @@ final readonly class PromptBuilder
}
if ($lines !== []) {
$parts = [
"LANGUAGE RULES:\n" .
implode("\n", $this->buildLanguageRules()),
"FACT GROUNDING RULES:\n" .
implode("\n", $this->buildFactGroundingRules($isTechnicalProductQuestion, $hasShopResults, $isPriceDrivenQuestion)),
"RETRIEVED KNOWLEDGE (primary for technical matching and factual explanation):\n" .
"Source: Documents\n" .
implode("\n\n", $lines),
];
$knowledgeParts[] = implode("\n\n", $parts);
$knowledgeParts[] = $this->implodeBlocks([
$this->buildRuleBlock(
$this->config->getLanguageRulesSectionLabel(),
$this->config->getLanguageRules()
),
$this->buildRuleBlock(
$this->config->getFactGroundingRulesSectionLabel(),
$this->buildFactGroundingRules(
hasShopResults: $hasShopResults,
isTechnicalProductQuestion: $isTechnicalProductQuestion
)
),
$this->implodeBlocks([
$this->config->getRetrievedKnowledgeSectionLabel() . ':',
$this->config->getRetrievedKnowledgeSourceLine(),
implode("\n\n", $lines),
]),
]);
}
}
if ($urlContent !== '') {
$knowledgeParts[] =
"CONTENT FROM URL (authoritative if user-provided):\n" .
"Source: URL\n" .
$urlContent;
$knowledgeParts[] = $this->implodeBlocks([
$this->config->getUrlContentSectionLabel() . ':',
$this->config->getUrlContentSourceLine(),
$urlContent,
]);
}
return $this->implodeBlocks($knowledgeParts);
}
/**
* 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();
$outputReserveTokens = $this->clamp(
(int) floor($numCtx * PromptBuilderConfig::OUTPUT_RESERVE_RATIO),
PromptBuilderConfig::OUTPUT_RESERVE_MIN_TOKENS,
PromptBuilderConfig::OUTPUT_RESERVE_MAX_TOKENS
(int) floor($numCtx * $this->config->getOutputReserveRatio()),
$this->config->getOutputReserveMinTokens(),
$this->config->getOutputReserveMaxTokens()
);
$safetyReserveTokens = $this->clamp(
(int) floor($numCtx * PromptBuilderConfig::SAFETY_RESERVE_RATIO),
PromptBuilderConfig::SAFETY_RESERVE_MIN_TOKENS,
PromptBuilderConfig::SAFETY_RESERVE_MAX_TOKENS
(int) floor($numCtx * $this->config->getSafetyReserveRatio()),
$this->config->getSafetyReserveMinTokens(),
$this->config->getSafetyReserveMaxTokens()
);
$promptBudgetTokens = max(
PromptBuilderConfig::MIN_PROMPT_BUDGET_TOKENS,
$this->config->getMinPromptBudgetTokens(),
$numCtx - $outputReserveTokens - $safetyReserveTokens
);
$promptBudgetChars = $promptBudgetTokens * PromptBuilderConfig::CHARS_PER_TOKEN;
$promptBudgetChars = $promptBudgetTokens * $this->config->getCharsPerToken();
$remaining = $promptBudgetChars
- mb_strlen($fixedPrompt)
- PromptBuilderConfig::HISTORY_PADDING_CHARS;
- $this->config->getHistoryPaddingChars();
return max(0, $remaining);
}
@@ -416,87 +356,118 @@ final readonly class PromptBuilder
/**
* @return string[]
*/
private function buildLanguageRules(): array
private function buildFactGroundingRules(bool $hasShopResults, bool $isTechnicalProductQuestion): array
{
return [
"- Answer only in the same language as the user question.",
"- All headings, labels, notes, and structural elements must be in the same language as the user question.",
"- Do not switch languages unless the user does.",
"- If headings are used, write them in the user's language.",
];
}
/**
* @return string[]
*/
private function buildFactGroundingRules(
bool $isTechnicalProductQuestion,
bool $hasShopResults,
bool $isPriceDrivenQuestion
): array {
$rules = [
"- State only facts that are explicitly present in the provided sources.",
"- Extract concrete values exactly when they are present, including units, ranges, model names, indicator names, IP classes, temperatures, pressures, dimensions, counts, relay outputs, current outputs, and error codes.",
"- Do not invent missing values.",
"- Do not replace missing values with estimates, defaults, or typical industry assumptions.",
"- Do not claim that information is missing if it appears in the provided sources.",
"- Do not compare with other products unless those products are also present in the provided sources.",
"- Prefer source-faithful wording over persuasive wording.",
"- Avoid marketing language such as 'ideal', 'perfect', 'unverzichtbar', 'entscheidend', 'optimal', 'kosteneffizient', 'prozesssicher', or 'state-of-the-art'.",
"- Clearly separate explicit facts from inferences.",
"- If a conclusion goes beyond the source wording, label it exactly as 'Inference:'.",
"- If a sentence cannot be traced to the provided sources, do not write it.",
"- Never mention external manufacturers, external brands, or external products unless they are explicitly present in the provided sources.",
"- If the sources do not identify a suitable product, do not invent one.",
];
$rules = $this->config->getFactGroundingBaseRules();
if ($hasShopResults) {
$rules = array_merge($rules, [
"- Use shop data as highest priority only for current commercial fields: price, availability, URL, and current shop-visible naming.",
"- Use retrieved knowledge as highest priority for technical matching, thresholds, measurement principles, and technical explanation.",
"- When shop results are present and relevant, include current price and the actual URL if available.",
"- Do not let accessories, bundles, or service items override a technically better product match unless the user explicitly asks for them.",
"- Do not call accessories, indicators, reagents, kits, sets, or consumables a device, measuring device, or main product unless the source explicitly says so.",
"- Do not claim that an accessory is required, necessary, used for calibration, or sets the measurement range unless this is explicitly stated in the provided sources.",
"- Do not assign the product number, price, URL, or availability of a reagent, accessory, kit, set, consumable, or service item to a device identified in retrieved knowledge.",
"- Only use commercial fields for the main product when the shop item and the technically identified product clearly refer to the same product identity.",
"- If the shop match is ambiguous, keep the technical identification and commercial details separate.",
]);
if ($isPriceDrivenQuestion) {
$rules[] = "- For price-threshold questions, shop prices are authoritative for the threshold check.";
$rules[] = "- Accessory-only shop hits do not prove that no qualifying device exists.";
}
$rules = array_merge($rules, $this->config->getFactGroundingWithShopRules());
} else {
$rules[] = "- Use retrieved knowledge as authoritative for factual answers.";
$rules[] = "- If no shop results are present, do not compensate with external recommendations or external product suggestions.";
$rules = array_merge($rules, $this->config->getFactGroundingWithoutShopRules());
}
if ($isTechnicalProductQuestion) {
$rules = array_merge($rules, [
"- For technical product questions, answer primarily with explicitly stated facts.",
"- Behave like a technical documentation assistant, not like a sales advisor.",
"- Keep interpretations minimal and do not generalize application areas beyond the provided sources.",
"- Do not describe benefits, consequences, risks, or operational outcomes unless they are explicitly stated in the sources.",
"- Do not translate technical facts into business value unless the source explicitly does so.",
"- Do not recommend process changes unless explicitly present in the source.",
"- Do not use persuasive summaries or advisory conclusions.",
"- If the retrieved knowledge describes one specific named product, stay within that product and do not merge related product families or variants.",
"- Use neutral engineering language.",
"- Do not name specific chemicals, indicator substances, standards, or mechanisms unless explicitly stated in the source.",
"- If the source states signal logic such as green/red, output that signal logic only and do not expand it into operational recommendations or alarm semantics unless explicitly stated.",
"- If the source lists application areas, repeat only those areas and do not broaden them.",
"- If the source names an indicator and threshold, reproduce that exactly without extrapolation.",
"- If the source states only a threshold function, do not expand it into broader control logic.",
"- If a detail is not explicitly stated in the provided sources, say so plainly.",
"- Prefer short, source-close sentences over explanatory expansion.",
"- If the sources only support that a product family is not suitable, output only that unsuitability and stop there.",
]);
$rules = array_merge($rules, $this->config->getFactGroundingTechnicalRules());
}
return $rules;
}
private function buildShopProductEntry(ShopProductResult $product, int $index, bool $isDetailed): string
{
$entryParts = [
"[{$index}] " . $this->normalizeBlockText($product->name),
];
if ($product->productNumber) {
$entryParts[] = $this->config->getShopProductNumberLabel() . ': '
. $this->normalizeBlockText($product->productNumber);
}
if ($product->manufacturer) {
$entryParts[] = $this->config->getShopManufacturerLabel() . ': '
. $this->normalizeBlockText($product->manufacturer);
}
if ($product->price) {
$entryParts[] = $this->config->getShopPriceLabel() . ': '
. $this->normalizeBlockText($product->price);
}
if ($product->available !== null) {
$entryParts[] = $this->config->getShopAvailabilityLabel() . ': '
. ($product->available
? $this->config->getShopAvailabilityYesLabel()
: $this->config->getShopAvailabilityNoLabel());
}
foreach ($product->highlights as $highlight) {
$highlight = $this->normalizeBlockText((string) $highlight);
if ($highlight !== '') {
$entryParts[] = $this->config->getShopHighlightPrefix() . $highlight;
}
}
if ($product->url) {
$entryParts[] = $this->config->getShopUrlLabel() . ': '
. $this->normalizeBlockText($product->url);
}
if ($product->productImage) {
$entryParts[] = $this->config->getShopProductImageLabel() . ': '
. $this->normalizeBlockText($product->productImage);
}
if ($isDetailed && $product->description) {
$entryParts[] = $this->config->getShopDescriptionLabel() . ': '
. $this->normalizeBlockText($product->description);
}
if ($product->customFields) {
$entryParts[] = $this->config->getShopMetaInformationLabel() . ': '
. $this->normalizeBlockText($product->customFields);
}
return implode("\n", $entryParts);
}
/**
* @param string[] $rules
*/
private function buildRuleBlock(string $sectionLabel, array $rules): string
{
$normalizedRules = array_values(array_filter(
array_map(
fn(string $rule): string => $this->normalizeBlockText($rule),
$rules
),
static fn(string $rule): bool => $rule !== ''
));
if ($normalizedRules === []) {
return '';
}
return $sectionLabel . ":\n" . implode("\n", $normalizedRules);
}
/**
* @param string[] $lines
*/
private function implodeLines(array $lines): string
{
$normalizedLines = array_values(array_filter(
array_map(
fn(string $line): string => $this->normalizeBlockText($line),
$lines
),
static fn(string $line): bool => $line !== ''
));
return implode("\n", $normalizedLines);
}
private function implodeBlocks(array $blocks): string
{
$filtered = array_values(array_filter(
@@ -537,41 +508,26 @@ final readonly class PromptBuilder
private function isLikelyTechnicalProductQuestion(string $prompt): bool
{
$normalized = mb_strtolower($prompt, 'UTF-8');
$matches = 0;
foreach (PromptBuilderConfig::TECHNICAL_PRODUCT_KEYWORDS as $keyword) {
foreach ($this->config->getTechnicalProductKeywords() as $keyword) {
if (str_contains($normalized, $keyword)) {
$matches++;
}
}
if ($matches >= 2) {
if ($matches >= $this->config->getTechnicalProductKeywordMatchThreshold()) {
return true;
}
return preg_match('/\b[\p{L}]{2,}\s?\d{2,5}\b/u', $prompt) === 1;
}
private function isLikelyPriceDrivenQuestion(string $prompt): bool
{
$normalized = mb_strtolower($prompt, 'UTF-8');
if (preg_match('/\b(mehr\s+als|über|ueber|größer\s+als|groesser\s+als|unter|bis|ab|mindestens|min)\s+\d+(?:[.,]\d+)?\s*(?:euro|eur|€)\b/u', $normalized) === 1) {
return true;
}
return str_contains($normalized, 'preis')
|| str_contains($normalized, 'preise')
|| str_contains($normalized, 'kosten')
|| str_contains($normalized, 'kostet');
return preg_match($this->config->getTechnicalProductModelPattern(), $prompt) === 1;
}
private function asksForAccessoryOrBundle(string $prompt): bool
{
$normalized = mb_strtolower($prompt, 'UTF-8');
foreach (PromptBuilderConfig::ACCESSORY_REQUEST_KEYWORDS as $keyword) {
foreach ($this->config->getAccessoryRequestKeywords() as $keyword) {
if (str_contains($normalized, $keyword)) {
return true;
}