Files
MtoRagSystem/src/Agent/PromptBuilder.php
2026-05-01 17:40:48 +02:00

1125 lines
40 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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));
}
}