move intent an config value into config files
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user