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 = '/(?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)); } }