1125 lines
40 KiB
PHP
1125 lines
40 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Agent;
|
||
|
||
use App\Commerce\Dto\ShopProductResult;
|
||
use App\Config\PromptBuilderConfig;
|
||
use App\Context\ContextService;
|
||
use App\Repository\SystemPromptRepository;
|
||
use App\Service\ModelGenerationConfigProvider;
|
||
use DateTimeImmutable;
|
||
use RuntimeException;
|
||
|
||
final readonly class PromptBuilder
|
||
{
|
||
public function __construct(
|
||
private ContextService $contextService,
|
||
private SystemPromptRepository $systemPromptRepository,
|
||
private ModelGenerationConfigProvider $modelGenerationConfigProvider,
|
||
private PromptBuilderConfig $config,
|
||
) {
|
||
}
|
||
|
||
/**
|
||
* Build the final prompt string for the LLM.
|
||
*
|
||
* @param string $prompt
|
||
* @param string $userId
|
||
* @param string $urlContent
|
||
* @param string[] $knowledgeChunks
|
||
* @param ShopProductResult[] $shopResults
|
||
* @param bool|null $fullContext
|
||
* @param string|null $swagFullOutPut
|
||
*/
|
||
public function build(
|
||
string $prompt,
|
||
string $userId,
|
||
string $urlContent,
|
||
array $knowledgeChunks,
|
||
array $shopResults = [],
|
||
?bool $fullContext = false,
|
||
?string $swagFullOutPut = '',
|
||
bool $commerceSearchAttempted = false,
|
||
bool $shopSearchHadSystemFailure = false,
|
||
string $knowledgeEvidenceState = 'unknown'
|
||
): string {
|
||
$prompt = $this->normalizeBlockText($prompt);
|
||
$urlContent = $this->normalizeBlockText($urlContent);
|
||
$swagFullOutPut = $this->normalizeNullableBlockText($swagFullOutPut);
|
||
|
||
$hasShopResults = $shopResults !== [];
|
||
$hasKnowledge = $knowledgeChunks !== [] || $urlContent !== '';
|
||
$isTechnicalProductQuestion = $this->isLikelyTechnicalProductQuestion($prompt);
|
||
$asksForAccessoryOrBundle = $this->asksForAccessoryOrBundle($prompt);
|
||
$requestedProductRole = $this->resolveRequestedProductRole($prompt);
|
||
$reliabilityState = $this->resolveReliabilityState(
|
||
hasKnowledge: $hasKnowledge,
|
||
hasShopResults: $hasShopResults,
|
||
commerceSearchAttempted: $commerceSearchAttempted,
|
||
shopSearchHadSystemFailure: $shopSearchHadSystemFailure,
|
||
knowledgeEvidenceState: $knowledgeEvidenceState
|
||
);
|
||
|
||
$systemBlock = $this->buildSystemBlock();
|
||
$shopBlock = $this->buildShopBlock($prompt, $shopResults, $swagFullOutPut, $requestedProductRole);
|
||
$measurementEvidenceBlock = $this->buildMeasurementEvidenceBlock(
|
||
prompt: $prompt,
|
||
knowledgeChunks: $knowledgeChunks,
|
||
urlContent: $urlContent,
|
||
shopResults: $shopResults,
|
||
requestedRole: $requestedProductRole
|
||
);
|
||
$outputPriorityBlock = $this->buildOutputPriorityBlock(
|
||
hasShopResults: $hasShopResults,
|
||
isTechnicalProductQuestion: $isTechnicalProductQuestion
|
||
);
|
||
$fallbackEscalationBlock = $this->buildFallbackEscalationBlock(
|
||
reliabilityState: $reliabilityState,
|
||
hasShopResults: $hasShopResults,
|
||
commerceSearchAttempted: $commerceSearchAttempted,
|
||
shopSearchHadSystemFailure: $shopSearchHadSystemFailure,
|
||
isTechnicalProductQuestion: $isTechnicalProductQuestion
|
||
);
|
||
$responseFormatBlock = $this->buildResponseFormatBlock(
|
||
hasShopResults: $hasShopResults,
|
||
isTechnicalProductQuestion: $isTechnicalProductQuestion,
|
||
asksForAccessoryOrBundle: $asksForAccessoryOrBundle
|
||
);
|
||
$knowledgeBlock = $this->buildKnowledgeBlock(
|
||
knowledgeChunks: $knowledgeChunks,
|
||
urlContent: $urlContent,
|
||
hasShopResults: $hasShopResults,
|
||
isTechnicalProductQuestion: $isTechnicalProductQuestion
|
||
);
|
||
$userBlock = $this->buildUserBlock($prompt);
|
||
|
||
$fixedPrompt = $this->implodeBlocks([
|
||
$systemBlock,
|
||
$shopBlock,
|
||
$measurementEvidenceBlock,
|
||
$outputPriorityBlock,
|
||
$fallbackEscalationBlock,
|
||
$responseFormatBlock,
|
||
$knowledgeBlock,
|
||
$userBlock,
|
||
]);
|
||
|
||
$contextBlock = $this->buildContextBlock(
|
||
userId: $userId,
|
||
fixedPrompt: $fixedPrompt,
|
||
fullContext: (bool) $fullContext
|
||
);
|
||
|
||
return $this->implodeBlocks([
|
||
$systemBlock,
|
||
$shopBlock,
|
||
$measurementEvidenceBlock,
|
||
$outputPriorityBlock,
|
||
$fallbackEscalationBlock,
|
||
$responseFormatBlock,
|
||
$knowledgeBlock,
|
||
$contextBlock,
|
||
$userBlock,
|
||
]);
|
||
}
|
||
|
||
private function buildSystemBlock(): string
|
||
{
|
||
$now = (new DateTimeImmutable())->format('Y-m-d H:i:s');
|
||
|
||
$activePrompt = $this->systemPromptRepository->findActive();
|
||
|
||
if (!$activePrompt) {
|
||
throw new RuntimeException('No active system prompt configured.');
|
||
}
|
||
|
||
$activeSystemPrompt = str_replace('{% now %}', $now, $activePrompt->getContent());
|
||
|
||
return $this->config->getSystemSectionLabel() . ":\n" . $this->normalizeBlockText($activeSystemPrompt);
|
||
}
|
||
|
||
private function buildUserBlock(string $prompt): string
|
||
{
|
||
return $this->config->getUserQuestionSectionLabel() . ":\n" . $prompt;
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
|
||
if ($historyBudgetChars <= 0) {
|
||
return '';
|
||
}
|
||
|
||
$history = $this->contextService->buildUserContextWithinBudget(
|
||
userId: $userId,
|
||
maxChars: $historyBudgetChars
|
||
);
|
||
}
|
||
|
||
$history = $this->normalizeBlockText($history);
|
||
|
||
if ($history === '') {
|
||
return '';
|
||
}
|
||
|
||
return $this->implodeBlocks([
|
||
$this->config->getConversationContextSectionLabel() . ':',
|
||
$this->implodeLines($this->config->getConversationContextIntroLines()),
|
||
$history,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Build the shop block.
|
||
*
|
||
* Shop data is the most current source for commercial details.
|
||
* It should not override technical matching logic.
|
||
*/
|
||
private function buildShopBlock(string $prompt, array $shopResults, ?string $swagFullOutPut, ?string $requestedProductRole = null): string
|
||
{
|
||
$parts = [];
|
||
|
||
if ($swagFullOutPut !== null && $swagFullOutPut !== '') {
|
||
$parts[] = $this->implodeBlocks([
|
||
$this->config->getShopSearchQuerySectionLabel() . ':',
|
||
$swagFullOutPut,
|
||
$this->config->getShopSearchQuerySourceLine(),
|
||
]);
|
||
}
|
||
|
||
$normalizedShopResults = array_values(array_filter(
|
||
$shopResults,
|
||
static fn(mixed $product): bool => $product instanceof ShopProductResult
|
||
));
|
||
|
||
if ($normalizedShopResults === []) {
|
||
return $this->implodeBlocks($parts);
|
||
}
|
||
|
||
$totalCount = count($normalizedShopResults);
|
||
$limitedShopResults = array_slice($normalizedShopResults, 0, $this->config->getMaxShopResultsInPrompt());
|
||
$isDetailed = count($limitedShopResults) <= $this->config->getDetailedShopResultsMaxCount();
|
||
$requestedRole = $requestedProductRole ?? $this->resolveRequestedProductRole($prompt);
|
||
$measurementGuard = $this->resolveRequestedMeasurementGuard($prompt);
|
||
$lines = [];
|
||
|
||
foreach ($limitedShopResults as $i => $product) {
|
||
$lines[] = $this->buildShopProductEntry(
|
||
product: $product,
|
||
index: $i + 1,
|
||
isDetailed: $isDetailed,
|
||
requestedRole: $requestedRole,
|
||
measurementGuard: $measurementGuard
|
||
);
|
||
}
|
||
|
||
if ($lines !== []) {
|
||
$headerLines = $this->config->getLiveShopResultsHeaderLines();
|
||
|
||
if ($totalCount > count($limitedShopResults)) {
|
||
$headerLines[] = sprintf(
|
||
$this->config->getLiveShopResultsOverflowNoticeTemplate(),
|
||
count($limitedShopResults),
|
||
$totalCount
|
||
);
|
||
}
|
||
|
||
$parts[] = $this->implodeBlocks([
|
||
$this->implodeLines($headerLines),
|
||
implode("\n\n", $lines),
|
||
]);
|
||
}
|
||
|
||
return $this->implodeBlocks($parts);
|
||
}
|
||
|
||
/**
|
||
* Build a small priority block that tells the model what to surface first.
|
||
*/
|
||
private function buildOutputPriorityBlock(bool $hasShopResults, bool $isTechnicalProductQuestion): string
|
||
{
|
||
$rules = [];
|
||
|
||
if ($isTechnicalProductQuestion) {
|
||
$rules = array_merge($rules, $this->config->getOutputPriorityTechnicalRules());
|
||
}
|
||
|
||
if ($hasShopResults) {
|
||
$rules = array_merge($rules, $this->config->getOutputPriorityRules());
|
||
}
|
||
|
||
if ($rules === []) {
|
||
return '';
|
||
}
|
||
|
||
return $this->buildRuleBlock(
|
||
$this->config->getOutputPrioritySectionLabel(),
|
||
$rules
|
||
);
|
||
}
|
||
|
||
private function resolveReliabilityState(
|
||
bool $hasKnowledge,
|
||
bool $hasShopResults,
|
||
bool $commerceSearchAttempted,
|
||
bool $shopSearchHadSystemFailure,
|
||
string $knowledgeEvidenceState = 'unknown'
|
||
): string {
|
||
$hasDirectKnowledgeEvidence = $knowledgeEvidenceState === 'direct' || $knowledgeEvidenceState === 'unknown' && $hasKnowledge;
|
||
$hasWeakKnowledgeEvidence = $knowledgeEvidenceState === 'weak';
|
||
|
||
if ($shopSearchHadSystemFailure && !$hasDirectKnowledgeEvidence) {
|
||
return $hasWeakKnowledgeEvidence
|
||
? 'semantische_rag_treffer_kein_direkter_fachbeleg_shopdaten_nicht_verfuegbar'
|
||
: 'shopdaten_nicht_verfuegbar';
|
||
}
|
||
|
||
if ($hasWeakKnowledgeEvidence && !$hasShopResults) {
|
||
return 'semantische_rag_treffer_kein_direkter_fachbeleg';
|
||
}
|
||
|
||
if ($hasDirectKnowledgeEvidence && !$hasShopResults) {
|
||
return 'sicher_beantwortbar';
|
||
}
|
||
|
||
if ($hasDirectKnowledgeEvidence && $hasShopResults) {
|
||
return 'wahrscheinlich_beantwortbar';
|
||
}
|
||
|
||
if (!$hasDirectKnowledgeEvidence && $hasShopResults) {
|
||
return 'nur_shop_treffer_kein_belastbares_fachwissen';
|
||
}
|
||
|
||
return 'keine_belastbaren_daten';
|
||
}
|
||
|
||
private function buildFallbackEscalationBlock(
|
||
string $reliabilityState,
|
||
bool $hasShopResults,
|
||
bool $commerceSearchAttempted,
|
||
bool $shopSearchHadSystemFailure,
|
||
bool $isTechnicalProductQuestion
|
||
): string {
|
||
$rules = [];
|
||
$stateLineTemplate = $this->config->getFallbackEscalationStateLineTemplate();
|
||
|
||
if ($stateLineTemplate !== '') {
|
||
$rules[] = str_replace('{state}', $reliabilityState, $stateLineTemplate);
|
||
}
|
||
|
||
$rules = array_merge($rules, $this->config->getFallbackEscalationBaseRules());
|
||
$rules = array_merge($rules, $this->config->getFallbackEscalationStateRules($reliabilityState));
|
||
|
||
if ($isTechnicalProductQuestion && !$commerceSearchAttempted && !$shopSearchHadSystemFailure) {
|
||
$rules = array_merge($rules, $this->config->getFallbackEscalationWithoutShopCheckRules());
|
||
}
|
||
|
||
if ($hasShopResults && !$commerceSearchAttempted) {
|
||
$rules[] = '- Treat shop results as provided context only; do not imply that a live shop check was performed in this run.';
|
||
}
|
||
|
||
if ($rules === []) {
|
||
return '';
|
||
}
|
||
|
||
return $this->buildRuleBlock(
|
||
$this->config->getFallbackEscalationSectionLabel(),
|
||
$rules
|
||
);
|
||
}
|
||
|
||
private function buildResponseFormatBlock(
|
||
bool $hasShopResults,
|
||
bool $isTechnicalProductQuestion,
|
||
bool $asksForAccessoryOrBundle
|
||
): string {
|
||
$rules = $this->config->getResponseFormatBaseRules();
|
||
|
||
if ($hasShopResults) {
|
||
$rules = array_merge($rules, $this->config->getResponseFormatWithShopRules());
|
||
} else {
|
||
$rules = array_merge($rules, $this->config->getResponseFormatWithoutShopRules());
|
||
}
|
||
|
||
if ($isTechnicalProductQuestion) {
|
||
$rules = array_merge($rules, $this->config->getResponseFormatTechnicalRules());
|
||
}
|
||
|
||
if ($asksForAccessoryOrBundle) {
|
||
$rules = array_merge($rules, $this->config->getResponseFormatAccessoryRules());
|
||
}
|
||
|
||
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,
|
||
bool $hasShopResults,
|
||
bool $isTechnicalProductQuestion
|
||
): string {
|
||
$knowledgeParts = [];
|
||
|
||
if ($knowledgeChunks !== []) {
|
||
$lines = [];
|
||
|
||
foreach ($knowledgeChunks as $i => $chunk) {
|
||
$chunk = $this->normalizeBlockText((string) $chunk);
|
||
|
||
if ($chunk === '') {
|
||
continue;
|
||
}
|
||
|
||
$n = $i + 1;
|
||
$lines[] = "[{$n}] RAG FACT RECORD\nRecord boundary: facts in this record must not be merged with accessory, indicator, reagent, price, URL, or product-number details from another record unless the same record explicitly connects them.\n" . $chunk;
|
||
}
|
||
|
||
if ($lines !== []) {
|
||
$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[] = $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 * $this->config->getOutputReserveRatio()),
|
||
$this->config->getOutputReserveMinTokens(),
|
||
$this->config->getOutputReserveMaxTokens()
|
||
);
|
||
|
||
$safetyReserveTokens = $this->clamp(
|
||
(int) floor($numCtx * $this->config->getSafetyReserveRatio()),
|
||
$this->config->getSafetyReserveMinTokens(),
|
||
$this->config->getSafetyReserveMaxTokens()
|
||
);
|
||
|
||
$promptBudgetTokens = max(
|
||
$this->config->getMinPromptBudgetTokens(),
|
||
$numCtx - $outputReserveTokens - $safetyReserveTokens
|
||
);
|
||
|
||
$promptBudgetChars = $promptBudgetTokens * $this->config->getCharsPerToken();
|
||
|
||
$remaining = $promptBudgetChars
|
||
- mb_strlen($fixedPrompt)
|
||
- $this->config->getHistoryPaddingChars();
|
||
|
||
return max(0, $remaining);
|
||
}
|
||
|
||
/**
|
||
* @return string[]
|
||
*/
|
||
private function buildFactGroundingRules(bool $hasShopResults, bool $isTechnicalProductQuestion): array
|
||
{
|
||
$rules = $this->config->getFactGroundingBaseRules();
|
||
|
||
if ($hasShopResults) {
|
||
$rules = array_merge($rules, $this->config->getFactGroundingWithShopRules());
|
||
} else {
|
||
$rules = array_merge($rules, $this->config->getFactGroundingWithoutShopRules());
|
||
}
|
||
|
||
if ($isTechnicalProductQuestion) {
|
||
$rules = array_merge($rules, $this->config->getFactGroundingTechnicalRules());
|
||
}
|
||
|
||
return $rules;
|
||
}
|
||
|
||
private function buildShopProductEntry(
|
||
ShopProductResult $product,
|
||
int $index,
|
||
bool $isDetailed,
|
||
string $requestedRole,
|
||
?array $measurementGuard = null
|
||
): string {
|
||
$productName = $this->normalizeBlockText($product->name);
|
||
|
||
$inferredRole = $this->resolveShopProductRole($product);
|
||
$roleCompatibility = $this->resolveShopRoleCompatibility($requestedRole, $inferredRole);
|
||
|
||
$entryParts = [
|
||
sprintf($this->config->getShopRecordHeaderTemplate(), $index),
|
||
$this->config->getShopExactProductNameLabel() . ': ' . $productName,
|
||
$this->config->getShopRequestedRoleLabel() . ': ' . $requestedRole,
|
||
$this->config->getShopInferredRoleLabel() . ': ' . $inferredRole,
|
||
$this->config->getShopRoleCompatibilityLabel() . ': ' . $roleCompatibility,
|
||
];
|
||
|
||
foreach ($this->config->getShopAtomicRecordNoteLines() as $noteLine) {
|
||
$noteLine = $this->normalizeBlockText($noteLine);
|
||
|
||
if ($noteLine !== '') {
|
||
$entryParts[] = $noteLine;
|
||
}
|
||
}
|
||
|
||
$measurementEvidenceLine = $this->buildShopMeasurementEvidenceLine($product, $measurementGuard);
|
||
if ($measurementEvidenceLine !== '') {
|
||
$entryParts[] = $measurementEvidenceLine;
|
||
}
|
||
|
||
$suppressCommercialFields = $requestedRole === 'main_device'
|
||
&& $roleCompatibility === 'incompatible_accessory_for_main_device_request';
|
||
|
||
if ($suppressCommercialFields) {
|
||
$entryParts[] = $this->config->getShopRoleIncompatibleCommercialSuppressionNote();
|
||
}
|
||
|
||
if (!$suppressCommercialFields && $product->productNumber) {
|
||
$entryParts[] = $this->config->getShopProductNumberLabel() . ': '
|
||
. $this->normalizeBlockText($product->productNumber);
|
||
}
|
||
|
||
if (!$suppressCommercialFields && $product->manufacturer) {
|
||
$entryParts[] = $this->config->getShopManufacturerLabel() . ': '
|
||
. $this->normalizeBlockText($product->manufacturer);
|
||
}
|
||
|
||
if (!$suppressCommercialFields && $product->price) {
|
||
$entryParts[] = $this->config->getShopPriceLabel() . ': '
|
||
. $this->normalizeBlockText($product->price);
|
||
}
|
||
|
||
if (!$suppressCommercialFields && $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 (!$suppressCommercialFields && $product->url) {
|
||
$entryParts[] = $this->config->getShopUrlLabel() . ': '
|
||
. $this->normalizeBlockText($product->url);
|
||
}
|
||
|
||
if (!$suppressCommercialFields && $product->productImage) {
|
||
$entryParts[] = $this->config->getShopProductImageLabel() . ': '
|
||
. $this->normalizeBlockText($product->productImage);
|
||
}
|
||
|
||
if (!$suppressCommercialFields && $isDetailed && $product->description) {
|
||
$entryParts[] = $this->config->getShopDescriptionLabel() . ': '
|
||
. $this->normalizeBlockText($product->description);
|
||
}
|
||
|
||
if (!$suppressCommercialFields && $product->customFields) {
|
||
$entryParts[] = $this->config->getShopMetaInformationLabel() . ': '
|
||
. $this->normalizeBlockText($product->customFields);
|
||
}
|
||
|
||
return implode("\n", $entryParts);
|
||
}
|
||
|
||
/**
|
||
* @param string[] $knowledgeChunks
|
||
* @param ShopProductResult[] $shopResults
|
||
*/
|
||
private function buildMeasurementEvidenceBlock(
|
||
string $prompt,
|
||
array $knowledgeChunks,
|
||
string $urlContent,
|
||
array $shopResults,
|
||
?string $requestedRole = null
|
||
): string {
|
||
$guard = $this->resolveRequestedMeasurementGuard($prompt);
|
||
|
||
if ($guard === null) {
|
||
return '';
|
||
}
|
||
|
||
$positiveTerms = $this->extractMeasurementGuardStringList($guard, 'positive_terms');
|
||
$positiveContextTerms = $this->extractMeasurementGuardStringList($guard, 'positive_context_terms');
|
||
$negativeContextTerms = $this->extractMeasurementGuardStringList($guard, 'negative_context_terms');
|
||
$nonEquivalentTerms = $this->extractMeasurementGuardStringList($guard, 'non_equivalent_terms');
|
||
$label = $this->normalizeBlockText((string) ($guard['label'] ?? 'requested measurement parameter'));
|
||
$resolvedRequestedRole = $requestedRole ?? $this->resolveRequestedProductRole($prompt);
|
||
$safeNoEvidenceAnswer = $this->normalizeBlockText((string) (
|
||
$resolvedRequestedRole === 'accessory_or_consumable'
|
||
? ($guard['safe_no_accessory_evidence_answer_de'] ?? $guard['safe_no_evidence_answer_de'] ?? '')
|
||
: ($guard['safe_no_evidence_answer_de'] ?? '')
|
||
));
|
||
|
||
$knowledgeText = $this->normalizeBlockText(implode("\n\n", array_map('strval', $knowledgeChunks)) . "\n\n" . $urlContent);
|
||
$knowledgeHasEvidence = $this->containsMeasurementPositiveEvidence($knowledgeText, $positiveTerms, $positiveContextTerms, $negativeContextTerms);
|
||
|
||
$shopEvidenceLines = [];
|
||
$shopHasEvidence = false;
|
||
|
||
foreach (array_values($shopResults) as $index => $product) {
|
||
if (!$product instanceof ShopProductResult) {
|
||
continue;
|
||
}
|
||
|
||
$hasEvidence = $this->shopProductHasMeasurementEvidence($product, $positiveTerms, $positiveContextTerms, $negativeContextTerms);
|
||
$productName = $this->normalizeBlockText($product->name);
|
||
|
||
if ($hasEvidence) {
|
||
$shopHasEvidence = true;
|
||
$shopEvidenceLines[] = sprintf(
|
||
'- Shop record %d (%s): explicit positive evidence for %s is present in this same record.',
|
||
$index + 1,
|
||
$productName !== '' ? $productName : 'unnamed product',
|
||
$label
|
||
);
|
||
}
|
||
}
|
||
|
||
if ($shopEvidenceLines === []) {
|
||
$shopEvidenceLines[] = sprintf(
|
||
'- No shop product record shown to the model contains explicit positive evidence for %s in the same record.',
|
||
$label
|
||
);
|
||
}
|
||
|
||
$rules = $this->config->getMeasurementEvidenceIntroRules();
|
||
$rules[] = '- User requested measurement parameter: ' . $label . '.';
|
||
$rules[] = '- Positive parameter terms for this request: ' . implode(', ', $positiveTerms) . '.';
|
||
if ($positiveContextTerms !== []) {
|
||
$rules[] = '- These parameter terms count as suitability evidence only in a measurement-purpose context such as: ' . implode(', ', $positiveContextTerms) . '.';
|
||
}
|
||
if ($negativeContextTerms !== []) {
|
||
$rules[] = '- These contexts are not suitability evidence by themselves: ' . implode(', ', $negativeContextTerms) . '.';
|
||
}
|
||
|
||
if ($nonEquivalentTerms !== []) {
|
||
$rules[] = '- Terms that must NOT be treated as equivalent positive evidence: ' . implode(', ', $nonEquivalentTerms) . '.';
|
||
}
|
||
|
||
$rules[] = '- RAG/URL evidence scan for this exact parameter: ' . ($knowledgeHasEvidence ? 'explicit positive evidence found.' : 'no explicit positive evidence found.');
|
||
$rules = array_merge($rules, $shopEvidenceLines);
|
||
|
||
if (!$knowledgeHasEvidence && !$shopHasEvidence) {
|
||
$rules[] = '- Mandatory answer behavior: do not recommend a product as suitable for this measurement parameter.';
|
||
if ($safeNoEvidenceAnswer !== '') {
|
||
$rules[] = '- Start the answer with this meaning in the user language: ' . $safeNoEvidenceAnswer;
|
||
}
|
||
if ($resolvedRequestedRole === 'accessory_or_consumable') {
|
||
$rules[] = '- Do not recommend accessories for a different measurement parameter just because they are accessories. If only accessories for other parameters are present, say that only non-matching accessory hits were found.';
|
||
} else {
|
||
$rules[] = '- You may list exact shop hits only as commercial/search hits under a heading such as "Shop-Treffer (technische Eignung nicht sicher belegt)".';
|
||
}
|
||
}
|
||
|
||
$rules[] = '- Do not output measurement ranges, methods, application areas, advantages, or alternative suitable models unless the same source record contains explicit positive evidence for the requested measurement parameter.';
|
||
$rules[] = '- The generated shop search query, search intent, ranking position, and user question are not factual evidence for product suitability.';
|
||
|
||
return $this->buildRuleBlock(
|
||
$this->config->getMeasurementEvidenceSectionLabel(),
|
||
$rules
|
||
);
|
||
}
|
||
|
||
private function buildShopMeasurementEvidenceLine(ShopProductResult $product, ?array $guard): string
|
||
{
|
||
if ($guard === null) {
|
||
return '';
|
||
}
|
||
|
||
$positiveTerms = $this->extractMeasurementGuardStringList($guard, 'positive_terms');
|
||
$positiveContextTerms = $this->extractMeasurementGuardStringList($guard, 'positive_context_terms');
|
||
$negativeContextTerms = $this->extractMeasurementGuardStringList($guard, 'negative_context_terms');
|
||
$label = $this->normalizeBlockText((string) ($guard['label'] ?? 'requested measurement parameter'));
|
||
|
||
if ($positiveTerms === []) {
|
||
return '';
|
||
}
|
||
|
||
if ($this->shopProductHasMeasurementEvidence($product, $positiveTerms, $positiveContextTerms, $negativeContextTerms)) {
|
||
return sprintf(
|
||
'Requested measurement evidence: explicit positive evidence for %s is present in this same SHOP PRODUCT RECORD.',
|
||
$label
|
||
);
|
||
}
|
||
|
||
return sprintf(
|
||
'Requested measurement evidence: no explicit positive evidence for %s is present in this SHOP PRODUCT RECORD. Do not present this record as technically suitable for that measurement parameter.',
|
||
$label
|
||
);
|
||
}
|
||
|
||
private function resolveRequestedMeasurementGuard(string $prompt): ?array
|
||
{
|
||
$normalizedPrompt = $this->normalizeForMeasurementMatching($prompt);
|
||
|
||
foreach ($this->config->getMeasurementEvidenceParameters() as $parameter) {
|
||
$requestTerms = $this->extractMeasurementGuardStringList($parameter, 'request_terms');
|
||
|
||
foreach ($requestTerms as $term) {
|
||
if ($this->containsMeasurementTerm($normalizedPrompt, $term)) {
|
||
return $parameter;
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @return string[]
|
||
*/
|
||
private function extractMeasurementGuardStringList(array $guard, string $key): array
|
||
{
|
||
$value = $guard[$key] ?? [];
|
||
|
||
if (!is_array($value)) {
|
||
return [];
|
||
}
|
||
|
||
$out = [];
|
||
foreach ($value as $item) {
|
||
if (!is_scalar($item)) {
|
||
continue;
|
||
}
|
||
|
||
$item = $this->normalizeBlockText((string) $item);
|
||
if ($item !== '' && !in_array($item, $out, true)) {
|
||
$out[] = $item;
|
||
}
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* @param string[] $positiveTerms
|
||
* @param string[] $positiveContextTerms
|
||
* @param string[] $negativeContextTerms
|
||
*/
|
||
private function shopProductHasMeasurementEvidence(
|
||
ShopProductResult $product,
|
||
array $positiveTerms,
|
||
array $positiveContextTerms,
|
||
array $negativeContextTerms
|
||
): bool {
|
||
foreach ($this->buildShopProductEvidenceFragments($product) as $fragment) {
|
||
if ($this->containsMeasurementPositiveEvidence($fragment, $positiveTerms, $positiveContextTerms, $negativeContextTerms)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* @return string[]
|
||
*/
|
||
private function buildShopProductEvidenceFragments(ShopProductResult $product): array
|
||
{
|
||
$fragments = array_filter([
|
||
$product->name,
|
||
$product->manufacturer,
|
||
$product->url,
|
||
implode(' ', array_map('strval', $product->highlights)),
|
||
$product->description,
|
||
$product->customFields,
|
||
], static fn($value): bool => is_scalar($value) && trim((string) $value) !== '');
|
||
|
||
$out = [];
|
||
foreach ($fragments as $fragment) {
|
||
foreach ($this->splitMeasurementEvidenceFragments((string) $fragment) as $part) {
|
||
if ($part !== '') {
|
||
$out[] = $part;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* @param string[] $positiveTerms
|
||
* @param string[] $positiveContextTerms
|
||
* @param string[] $negativeContextTerms
|
||
*/
|
||
private function containsMeasurementPositiveEvidence(
|
||
string $text,
|
||
array $positiveTerms,
|
||
array $positiveContextTerms,
|
||
array $negativeContextTerms
|
||
): bool {
|
||
foreach ($this->splitMeasurementEvidenceFragments($text) as $fragment) {
|
||
$normalizedFragment = $this->normalizeForMeasurementMatching($fragment);
|
||
|
||
if ($normalizedFragment === '' || !$this->containsAnyMeasurementTerm($normalizedFragment, $positiveTerms)) {
|
||
continue;
|
||
}
|
||
|
||
if ($negativeContextTerms !== [] && $this->containsAnyMeasurementTerm($normalizedFragment, $negativeContextTerms)) {
|
||
continue;
|
||
}
|
||
|
||
if ($positiveContextTerms === [] || $this->containsAnyMeasurementTerm($normalizedFragment, $positiveContextTerms)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* @param string[] $terms
|
||
*/
|
||
private function containsAnyMeasurementTerm(string $normalizedText, array $terms): bool
|
||
{
|
||
foreach ($terms as $term) {
|
||
if ($this->containsMeasurementTerm($normalizedText, $term)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* @return string[]
|
||
*/
|
||
private function splitMeasurementEvidenceFragments(string $text): array
|
||
{
|
||
$text = $this->normalizeBlockText($text);
|
||
if ($text === '') {
|
||
return [];
|
||
}
|
||
|
||
$parts = preg_split('/[\n.;|]+/u', $text) ?: [$text];
|
||
|
||
return array_values(array_filter(
|
||
array_map(fn(string $part): string => $this->normalizeBlockText($part), $parts),
|
||
static fn(string $part): bool => $part !== ''
|
||
));
|
||
}
|
||
|
||
private function containsMeasurementTerm(string $normalizedText, string $term): bool
|
||
{
|
||
$normalizedTerm = $this->normalizeForMeasurementMatching($term);
|
||
|
||
if ($normalizedText === '' || $normalizedTerm === '') {
|
||
return false;
|
||
}
|
||
|
||
if (preg_match('/[\p{L}\p{N}]/u', $normalizedTerm) !== 1) {
|
||
return str_contains($normalizedText, $normalizedTerm);
|
||
}
|
||
|
||
$pattern = '/(?<![\p{L}\p{N}])' . preg_quote($normalizedTerm, '/') . '(?![\p{L}\p{N}])/u';
|
||
|
||
return preg_match($pattern, $normalizedText) === 1;
|
||
}
|
||
|
||
private function normalizeForMeasurementMatching(string $value): string
|
||
{
|
||
$value = mb_strtolower($this->normalizeBlockText($value), 'UTF-8');
|
||
$value = str_replace(['‐', '‑', '‒', '–', '—'], '-', $value);
|
||
$value = preg_replace('/<[^>]+>/u', ' ', $value) ?? $value;
|
||
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||
|
||
return trim($value);
|
||
}
|
||
|
||
/**
|
||
* @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(
|
||
array_map(
|
||
fn($block): string => is_string($block) ? $this->normalizeBlockText($block) : '',
|
||
$blocks
|
||
),
|
||
static fn(string $block): bool => $block !== ''
|
||
));
|
||
|
||
return implode("\n\n", $filtered);
|
||
}
|
||
|
||
private function resolveRequestedProductRole(string $prompt): string
|
||
{
|
||
$normalized = mb_strtolower($this->normalizeBlockText($prompt), 'UTF-8');
|
||
$hasAccessoryIntent = $this->containsAnyPromptKeyword($normalized, $this->config->getAccessoryProductRoleKeywords());
|
||
$hasMainDeviceIntent = $this->containsAnyPromptKeyword($normalized, $this->config->getMainDeviceRequestRoleKeywords());
|
||
|
||
if ($hasAccessoryIntent && !$this->hasDirectMainDeviceRequest($normalized)) {
|
||
return 'accessory_or_consumable';
|
||
}
|
||
|
||
if ($hasMainDeviceIntent) {
|
||
return 'main_device';
|
||
}
|
||
|
||
if ($hasAccessoryIntent) {
|
||
return 'accessory_or_consumable';
|
||
}
|
||
|
||
return 'unknown';
|
||
}
|
||
|
||
private function hasDirectMainDeviceRequest(string $normalizedPrompt): bool
|
||
{
|
||
foreach ($this->config->getDirectMainDeviceRequestPatterns() as $pattern) {
|
||
if (preg_match($pattern, $normalizedPrompt) === 1) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function resolveShopProductRole(ShopProductResult $product): string
|
||
{
|
||
$primaryRole = $this->resolveShopPrimaryProductRole($product);
|
||
|
||
if ($primaryRole !== 'unknown') {
|
||
return $primaryRole;
|
||
}
|
||
|
||
$corpus = mb_strtolower(implode(' ', array_filter([
|
||
$product->name,
|
||
$product->productNumber,
|
||
$product->manufacturer,
|
||
implode(' ', $product->highlights),
|
||
$product->description,
|
||
$product->customFields,
|
||
$product->url,
|
||
])), 'UTF-8');
|
||
|
||
$isAccessory = $this->containsAnyPromptKeyword($corpus, $this->config->getAccessoryProductRoleKeywords());
|
||
$isMainDevice = $this->containsAnyPromptKeyword($corpus, $this->config->getMainDeviceProductRoleKeywords());
|
||
|
||
if ($isAccessory) {
|
||
return 'accessory_or_consumable';
|
||
}
|
||
|
||
if ($isMainDevice) {
|
||
return 'main_device';
|
||
}
|
||
|
||
return 'unknown';
|
||
}
|
||
|
||
private function resolveShopPrimaryProductRole(ShopProductResult $product): string
|
||
{
|
||
$primaryText = mb_strtolower(implode(' ', array_filter([
|
||
$product->name,
|
||
$product->url,
|
||
])), 'UTF-8');
|
||
|
||
if ($this->normalizeBlockText($primaryText) === '') {
|
||
return 'unknown';
|
||
}
|
||
|
||
$isAccessory = $this->containsAnyPromptKeyword($primaryText, $this->config->getAccessoryProductRoleKeywords());
|
||
$isMainDevice = $this->containsAnyPromptKeyword($primaryText, $this->config->getMainDeviceProductRoleKeywords());
|
||
|
||
if ($isAccessory) {
|
||
return 'accessory_or_consumable';
|
||
}
|
||
|
||
if ($isMainDevice) {
|
||
return 'main_device';
|
||
}
|
||
|
||
return 'unknown';
|
||
}
|
||
|
||
private function resolveShopRoleCompatibility(string $requestedRole, string $inferredRole): string
|
||
{
|
||
if ($requestedRole === 'unknown' || $inferredRole === 'unknown') {
|
||
return 'unknown';
|
||
}
|
||
|
||
if ($requestedRole === 'main_device' && $inferredRole === 'accessory_or_consumable') {
|
||
return 'incompatible_accessory_for_main_device_request';
|
||
}
|
||
|
||
if ($requestedRole === 'accessory_or_consumable' && $inferredRole === 'main_device') {
|
||
return 'incompatible_main_device_for_accessory_request';
|
||
}
|
||
|
||
if ($inferredRole === 'ambiguous_mixed_role') {
|
||
return 'ambiguous_keep_separate';
|
||
}
|
||
|
||
return 'compatible';
|
||
}
|
||
|
||
/**
|
||
* @param string[] $keywords
|
||
*/
|
||
private function containsAnyPromptKeyword(string $text, array $keywords): bool
|
||
{
|
||
foreach ($keywords as $keyword) {
|
||
$keyword = mb_strtolower($this->normalizeBlockText((string) $keyword), 'UTF-8');
|
||
|
||
if ($keyword !== '' && str_contains($text, $keyword)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function normalizeNullableBlockText(?string $value): ?string
|
||
{
|
||
if ($value === null) {
|
||
return null;
|
||
}
|
||
|
||
$normalized = $this->normalizeBlockText($value);
|
||
|
||
return $normalized === '' ? null : $normalized;
|
||
}
|
||
|
||
private function normalizeBlockText(string $value): string
|
||
{
|
||
$value = str_replace(["\r\n", "\r"], "\n", $value);
|
||
$value = str_replace("\u{00A0}", ' ', $value);
|
||
$value = trim($value);
|
||
|
||
$value = preg_replace("/\n{3,}/", "\n\n", $value) ?? $value;
|
||
$value = preg_replace("/[ \t]+\n/", "\n", $value) ?? $value;
|
||
$value = preg_replace("/[ \t]{2,}/", " ", $value) ?? $value;
|
||
|
||
return $value;
|
||
}
|
||
|
||
private function isLikelyTechnicalProductQuestion(string $prompt): bool
|
||
{
|
||
$normalized = mb_strtolower($prompt, 'UTF-8');
|
||
$matches = 0;
|
||
|
||
foreach ($this->config->getTechnicalProductKeywords() as $keyword) {
|
||
if (str_contains($normalized, $keyword)) {
|
||
$matches++;
|
||
}
|
||
}
|
||
|
||
if ($matches >= $this->config->getTechnicalProductKeywordMatchThreshold()) {
|
||
return true;
|
||
}
|
||
|
||
return preg_match($this->config->getTechnicalProductModelPattern(), $prompt) === 1;
|
||
}
|
||
|
||
private function asksForAccessoryOrBundle(string $prompt): bool
|
||
{
|
||
$normalized = mb_strtolower($prompt, 'UTF-8');
|
||
|
||
foreach ($this->config->getAccessoryRequestKeywords() as $keyword) {
|
||
if (str_contains($normalized, $keyword)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function clamp(int $value, int $min, int $max): int
|
||
{
|
||
return max($min, min($max, $value));
|
||
}
|
||
} |