optimize technical truth
This commit is contained in:
@@ -52,7 +52,6 @@ final readonly class PromptBuilder
|
||||
$hasKnowledge = $knowledgeChunks !== [] || $urlContent !== '';
|
||||
$isTechnicalProductQuestion = $this->isLikelyTechnicalProductQuestion($prompt);
|
||||
$asksForAccessoryOrBundle = $this->asksForAccessoryOrBundle($prompt);
|
||||
$requestedProductRole = $this->resolveRequestedProductRole($prompt);
|
||||
$reliabilityState = $this->resolveReliabilityState(
|
||||
hasKnowledge: $hasKnowledge,
|
||||
hasShopResults: $hasShopResults,
|
||||
@@ -61,11 +60,7 @@ final readonly class PromptBuilder
|
||||
);
|
||||
|
||||
$systemBlock = $this->buildSystemBlock();
|
||||
$shopBlock = $this->buildShopBlock(
|
||||
shopResults: $shopResults,
|
||||
swagFullOutPut: $swagFullOutPut,
|
||||
requestedProductRole: $requestedProductRole
|
||||
);
|
||||
$shopBlock = $this->buildShopBlock($prompt, $shopResults, $swagFullOutPut);
|
||||
$outputPriorityBlock = $this->buildOutputPriorityBlock(
|
||||
hasShopResults: $hasShopResults,
|
||||
isTechnicalProductQuestion: $isTechnicalProductQuestion
|
||||
@@ -183,7 +178,7 @@ final readonly class PromptBuilder
|
||||
* Shop data is the most current source for commercial details.
|
||||
* It should not override technical matching logic.
|
||||
*/
|
||||
private function buildShopBlock(array $shopResults, ?string $swagFullOutPut, string $requestedProductRole): string
|
||||
private function buildShopBlock(string $prompt, array $shopResults, ?string $swagFullOutPut): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
@@ -214,7 +209,7 @@ final readonly class PromptBuilder
|
||||
product: $product,
|
||||
index: $i + 1,
|
||||
isDetailed: $isDetailed,
|
||||
requestedProductRole: $requestedProductRole
|
||||
requestedRole: $this->resolveRequestedProductRole($prompt)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -377,7 +372,7 @@ final readonly class PromptBuilder
|
||||
}
|
||||
|
||||
$n = $i + 1;
|
||||
$lines[] = "[{$n}] {$chunk}";
|
||||
$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 !== []) {
|
||||
@@ -470,23 +465,18 @@ final readonly class PromptBuilder
|
||||
return $rules;
|
||||
}
|
||||
|
||||
private function buildShopProductEntry(
|
||||
ShopProductResult $product,
|
||||
int $index,
|
||||
bool $isDetailed,
|
||||
string $requestedProductRole
|
||||
): string {
|
||||
private function buildShopProductEntry(ShopProductResult $product, int $index, bool $isDetailed, string $requestedRole): string
|
||||
{
|
||||
$productName = $this->normalizeBlockText($product->name);
|
||||
$productRole = $this->resolveShopProductRole($product);
|
||||
$roleCompatibility = $this->resolveShopProductRoleCompatibility($requestedProductRole, $productRole);
|
||||
$isMainDeviceRequestAccessoryMismatch = $requestedProductRole === 'main_device_or_system'
|
||||
&& $productRole === 'accessory_or_consumable';
|
||||
|
||||
$inferredRole = $this->resolveShopProductRole($product);
|
||||
$roleCompatibility = $this->resolveShopRoleCompatibility($requestedRole, $inferredRole);
|
||||
|
||||
$entryParts = [
|
||||
sprintf($this->config->getShopRecordHeaderTemplate(), $index),
|
||||
$this->config->getShopExactProductNameLabel() . ': ' . $productName,
|
||||
$this->config->getShopRequestedRoleLabel() . ': ' . $requestedProductRole,
|
||||
$this->config->getShopInferredRoleLabel() . ': ' . $productRole,
|
||||
$this->config->getShopRequestedRoleLabel() . ': ' . $requestedRole,
|
||||
$this->config->getShopInferredRoleLabel() . ': ' . $inferredRole,
|
||||
$this->config->getShopRoleCompatibilityLabel() . ': ' . $roleCompatibility,
|
||||
];
|
||||
|
||||
@@ -498,56 +488,59 @@ final readonly class PromptBuilder
|
||||
}
|
||||
}
|
||||
|
||||
if ($product->productNumber) {
|
||||
$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 ($product->manufacturer) {
|
||||
if (!$suppressCommercialFields && $product->manufacturer) {
|
||||
$entryParts[] = $this->config->getShopManufacturerLabel() . ': '
|
||||
. $this->normalizeBlockText($product->manufacturer);
|
||||
}
|
||||
|
||||
if ($product->price) {
|
||||
if (!$suppressCommercialFields && $product->price) {
|
||||
$entryParts[] = $this->config->getShopPriceLabel() . ': '
|
||||
. $this->normalizeBlockText($product->price);
|
||||
}
|
||||
|
||||
if ($product->available !== null) {
|
||||
if (!$suppressCommercialFields && $product->available !== null) {
|
||||
$entryParts[] = $this->config->getShopAvailabilityLabel() . ': '
|
||||
. ($product->available
|
||||
? $this->config->getShopAvailabilityYesLabel()
|
||||
: $this->config->getShopAvailabilityNoLabel());
|
||||
}
|
||||
|
||||
if (!$isMainDeviceRequestAccessoryMismatch) {
|
||||
foreach ($product->highlights as $highlight) {
|
||||
$highlight = $this->normalizeBlockText((string) $highlight);
|
||||
foreach ($product->highlights as $highlight) {
|
||||
$highlight = $this->normalizeBlockText((string) $highlight);
|
||||
|
||||
if ($highlight !== '') {
|
||||
$entryParts[] = $this->config->getShopHighlightPrefix() . $highlight;
|
||||
}
|
||||
if ($highlight !== '') {
|
||||
$entryParts[] = $this->config->getShopHighlightPrefix() . $highlight;
|
||||
}
|
||||
} else {
|
||||
$entryParts[] = $this->config->getShopRoleMismatchNotice();
|
||||
}
|
||||
|
||||
if ($product->url) {
|
||||
if (!$suppressCommercialFields && $product->url) {
|
||||
$entryParts[] = $this->config->getShopUrlLabel() . ': '
|
||||
. $this->normalizeBlockText($product->url);
|
||||
}
|
||||
|
||||
if ($product->productImage) {
|
||||
if (!$suppressCommercialFields && $product->productImage) {
|
||||
$entryParts[] = $this->config->getShopProductImageLabel() . ': '
|
||||
. $this->normalizeBlockText($product->productImage);
|
||||
}
|
||||
|
||||
if (!$isMainDeviceRequestAccessoryMismatch && $isDetailed && $product->description) {
|
||||
if (!$suppressCommercialFields && $isDetailed && $product->description) {
|
||||
$entryParts[] = $this->config->getShopDescriptionLabel() . ': '
|
||||
. $this->normalizeBlockText($product->description);
|
||||
}
|
||||
|
||||
if (!$isMainDeviceRequestAccessoryMismatch && $product->customFields) {
|
||||
if (!$suppressCommercialFields && $product->customFields) {
|
||||
$entryParts[] = $this->config->getShopMetaInformationLabel() . ': '
|
||||
. $this->normalizeBlockText($product->customFields);
|
||||
}
|
||||
@@ -604,6 +597,123 @@ final readonly class PromptBuilder
|
||||
return implode("\n\n", $filtered);
|
||||
}
|
||||
|
||||
private function resolveRequestedProductRole(string $prompt): string
|
||||
{
|
||||
$normalized = mb_strtolower($prompt, 'UTF-8');
|
||||
|
||||
if ($this->containsAnyPromptKeyword($normalized, $this->config->getMainDeviceRequestRoleKeywords())) {
|
||||
return 'main_device';
|
||||
}
|
||||
|
||||
if ($this->containsAnyPromptKeyword($normalized, $this->config->getAccessoryProductRoleKeywords())) {
|
||||
return 'accessory_or_consumable';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
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 && !$isMainDevice) {
|
||||
return 'accessory_or_consumable';
|
||||
}
|
||||
|
||||
if ($isMainDevice && !$isAccessory) {
|
||||
return 'main_device';
|
||||
}
|
||||
|
||||
if ($isMainDevice && $isAccessory) {
|
||||
return 'ambiguous_mixed_role';
|
||||
}
|
||||
|
||||
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 && !$isMainDevice) {
|
||||
return 'accessory_or_consumable';
|
||||
}
|
||||
|
||||
if ($isMainDevice && !$isAccessory) {
|
||||
return 'main_device';
|
||||
}
|
||||
|
||||
if ($isMainDevice && $isAccessory) {
|
||||
return 'ambiguous_mixed_role';
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -628,81 +738,6 @@ final readonly class PromptBuilder
|
||||
return $value;
|
||||
}
|
||||
|
||||
|
||||
private function resolveRequestedProductRole(string $prompt): string
|
||||
{
|
||||
$normalized = mb_strtolower($prompt, 'UTF-8');
|
||||
|
||||
if ($this->containsAnyConfiguredTerm($normalized, $this->config->getAccessoryProductRoleKeywords())) {
|
||||
return 'accessory_or_consumable';
|
||||
}
|
||||
|
||||
if ($this->containsAnyConfiguredTerm($normalized, $this->config->getMainDeviceRequestRoleKeywords())) {
|
||||
return 'main_device_or_system';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private function resolveShopProductRole(ShopProductResult $product): string
|
||||
{
|
||||
$text = mb_strtolower($this->implodeLines([
|
||||
$product->name,
|
||||
(string) $product->description,
|
||||
(string) $product->customFields,
|
||||
implode(' ', $product->highlights),
|
||||
]), 'UTF-8');
|
||||
|
||||
// Accessory/consumable wins over broad device-family words such as "Testomat".
|
||||
// Example: "Testomat Indikator TH2005" must stay an indicator, not a main device.
|
||||
if ($this->containsAnyConfiguredTerm($text, $this->config->getAccessoryProductRoleKeywords())) {
|
||||
return 'accessory_or_consumable';
|
||||
}
|
||||
|
||||
if ($this->containsAnyConfiguredTerm($text, $this->config->getMainDeviceProductRoleKeywords())) {
|
||||
return 'main_device_or_system';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private function resolveShopProductRoleCompatibility(string $requestedProductRole, string $shopProductRole): string
|
||||
{
|
||||
if ($requestedProductRole === 'unknown' || $shopProductRole === 'unknown') {
|
||||
return 'unknown - do not use as primary product unless suitability is explicit in the same record';
|
||||
}
|
||||
|
||||
if ($requestedProductRole === $shopProductRole) {
|
||||
return 'compatible';
|
||||
}
|
||||
|
||||
if ($requestedProductRole === 'main_device_or_system' && $shopProductRole === 'accessory_or_consumable') {
|
||||
return 'not compatible - user asked for a main device/system, this shop record is accessory/consumable';
|
||||
}
|
||||
|
||||
if ($requestedProductRole === 'accessory_or_consumable' && $shopProductRole === 'main_device_or_system') {
|
||||
return 'not compatible - user asked for accessory/consumable, this shop record is a main device/system';
|
||||
}
|
||||
|
||||
return 'unknown - keep separate from the main answer';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $terms
|
||||
*/
|
||||
private function containsAnyConfiguredTerm(string $haystack, array $terms): bool
|
||||
{
|
||||
foreach ($terms as $term) {
|
||||
$term = mb_strtolower(trim($term), 'UTF-8');
|
||||
|
||||
if ($term !== '' && str_contains($haystack, $term)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isLikelyTechnicalProductQuestion(string $prompt): bool
|
||||
{
|
||||
$normalized = mb_strtolower($prompt, 'UTF-8');
|
||||
|
||||
@@ -175,6 +175,12 @@ final class ShopSearchService
|
||||
referenceContext: $referenceContext
|
||||
);
|
||||
|
||||
$finalProducts = $this->applyRoleGuardrails(
|
||||
products: $finalProducts,
|
||||
query: $primaryQuery,
|
||||
originalPrompt: $originalPrompt
|
||||
);
|
||||
|
||||
$finalProducts = $this->applyPriceFilters(
|
||||
products: $finalProducts,
|
||||
query: $primaryQuery
|
||||
@@ -984,6 +990,69 @@ final class ShopSearchService
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ShopProductResult[] $products
|
||||
* @return ShopProductResult[]
|
||||
*/
|
||||
private function applyRoleGuardrails(array $products, CommerceSearchQuery $query, string $originalPrompt): array
|
||||
{
|
||||
if ($products === [] || !$this->shopConfig->shouldFilterAccessoryProductsForDeviceQueries()) {
|
||||
return $products;
|
||||
}
|
||||
|
||||
$normalizedQuery = $this->normalizeForMatching(trim(implode(' ', array_filter([
|
||||
$query->normalizedPrompt !== '' ? $query->normalizedPrompt : $query->originalPrompt,
|
||||
$query->searchText,
|
||||
$originalPrompt,
|
||||
]))));
|
||||
|
||||
if (!$this->isDeviceQuery($normalizedQuery) || $this->isAccessoryQuery($normalizedQuery)) {
|
||||
return $products;
|
||||
}
|
||||
|
||||
$filtered = [];
|
||||
$excluded = [];
|
||||
|
||||
foreach ($products as $product) {
|
||||
if (!$product instanceof ShopProductResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isAccessoryLike = $this->isAccessoryLikeProduct($product);
|
||||
$isDeviceLike = $this->isDeviceLikeProduct($product);
|
||||
|
||||
if ($isAccessoryLike && !$isDeviceLike) {
|
||||
$excluded[] = $product;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$isDeviceLike && !$this->shopConfig->shouldKeepAmbiguousProductsForDeviceQueries()) {
|
||||
$excluded[] = $product;
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[] = $product;
|
||||
}
|
||||
|
||||
if ($excluded !== []) {
|
||||
$this->logger->info('Shop role guard excluded accessory-like products for device query', [
|
||||
'originalPrompt' => $originalPrompt,
|
||||
'searchText' => $query->searchText,
|
||||
'excludedCount' => count($excluded),
|
||||
'keptCount' => count($filtered),
|
||||
'excludedProducts' => array_map(
|
||||
static fn(ShopProductResult $product): array => [
|
||||
'name' => $product->name,
|
||||
'productNumber' => $product->productNumber,
|
||||
],
|
||||
array_slice($excluded, 0, $this->shopConfig->getTopProductLogLimit())
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return array_values($filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $focusTerms
|
||||
*/
|
||||
@@ -1261,23 +1330,89 @@ final class ShopSearchService
|
||||
|
||||
private function isAccessoryLikeProduct(ShopProductResult $product): bool
|
||||
{
|
||||
$corpus = $this->buildNormalizedProductCorpus($product);
|
||||
$primaryRole = $this->resolvePrimaryShopProductRole($product);
|
||||
|
||||
foreach ($this->shopConfig->getAccessoryProductKeywords() as $keyword) {
|
||||
if (str_contains($corpus, $this->normalizeForMatching($keyword))) {
|
||||
return true;
|
||||
}
|
||||
if ($primaryRole === 'accessory_or_consumable') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
if ($primaryRole === 'main_device') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->containsAnyShopKeyword(
|
||||
$this->buildNormalizedProductCorpus($product),
|
||||
$this->shopConfig->getAccessoryProductKeywords()
|
||||
);
|
||||
}
|
||||
|
||||
private function isDeviceLikeProduct(ShopProductResult $product): bool
|
||||
{
|
||||
$corpus = $this->buildNormalizedProductCorpus($product);
|
||||
$primaryRole = $this->resolvePrimaryShopProductRole($product);
|
||||
|
||||
foreach ($this->shopConfig->getDeviceProductKeywords() as $keyword) {
|
||||
if (str_contains($corpus, $this->normalizeForMatching($keyword))) {
|
||||
if ($primaryRole === 'main_device') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($primaryRole === 'accessory_or_consumable') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->containsAnyShopKeyword(
|
||||
$this->buildNormalizedProductCorpus($product),
|
||||
$this->shopConfig->getDeviceProductKeywords()
|
||||
);
|
||||
}
|
||||
|
||||
private function resolvePrimaryShopProductRole(ShopProductResult $product): string
|
||||
{
|
||||
$primaryText = $this->buildNormalizedPrimaryProductIdentity($product);
|
||||
|
||||
if ($primaryText === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$isAccessoryLike = $this->containsAnyShopKeyword(
|
||||
$primaryText,
|
||||
$this->shopConfig->getAccessoryProductKeywords()
|
||||
);
|
||||
$isDeviceLike = $this->containsAnyShopKeyword(
|
||||
$primaryText,
|
||||
$this->shopConfig->getDeviceProductKeywords()
|
||||
);
|
||||
|
||||
if ($isAccessoryLike && !$isDeviceLike) {
|
||||
return 'accessory_or_consumable';
|
||||
}
|
||||
|
||||
if ($isDeviceLike && !$isAccessoryLike) {
|
||||
return 'main_device';
|
||||
}
|
||||
|
||||
if ($isAccessoryLike && $isDeviceLike) {
|
||||
return 'ambiguous_mixed_role';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private function buildNormalizedPrimaryProductIdentity(ShopProductResult $product): string
|
||||
{
|
||||
return $this->normalizeForMatching(implode(' ', array_filter([
|
||||
$product->name,
|
||||
$product->url,
|
||||
])));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $keywords
|
||||
*/
|
||||
private function containsAnyShopKeyword(string $normalizedText, array $keywords): bool
|
||||
{
|
||||
foreach ($keywords as $keyword) {
|
||||
$normalizedKeyword = $this->normalizeForMatching((string) $keyword);
|
||||
|
||||
if ($normalizedKeyword !== '' && str_contains($normalizedText, $normalizedKeyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,29 @@ namespace App\Config;
|
||||
|
||||
final class PromptBuilderConfig
|
||||
{
|
||||
private const MAIN_DEVICE_REQUEST_ROLE_KEYWORDS = [
|
||||
'messanlage', 'messanlagen', 'anlage', 'anlagen', 'messgerät', 'messgeraet',
|
||||
'messgeräte', 'messgeraete', 'analysegerät', 'analysegeraet', 'analysegeräte',
|
||||
'analysegeraete', 'analysator', 'analysatoren', 'analyzer', 'gerät', 'geraet',
|
||||
'geräte', 'geraete', 'system', 'systeme', 'monitor', 'monitore', 'controller',
|
||||
];
|
||||
|
||||
private const MAIN_DEVICE_PRODUCT_ROLE_KEYWORDS = [
|
||||
'messanlage', 'messanlagen', 'messgerät', 'messgeraet', 'messgeräte', 'messgeraete',
|
||||
'analysegerät', 'analysegeraet', 'analysegeräte', 'analysegeraete', 'analysator',
|
||||
'analysatoren', 'analyzer', 'online-analysator', 'online analysator',
|
||||
'online-analysegerät', 'online analysegeraet', 'gerät', 'geraet', 'geräte',
|
||||
'geraete', 'system', 'systeme', 'monitor', 'monitore', 'controller',
|
||||
];
|
||||
|
||||
private const ACCESSORY_PRODUCT_ROLE_KEYWORDS = [
|
||||
'indikator', 'indikatoren', 'indicator', 'reagenz', 'reagenzien', 'reagent',
|
||||
'zubehör', 'zubehor', 'ersatzteil', 'ersatzteile', 'kit', 'set',
|
||||
'verbrauchsmaterial', 'consumable', 'nachfüll', 'nachfuell', 'refill',
|
||||
'lösung', 'loesung', 'solution', 'teststreifen', 'test strip', 'filter',
|
||||
'pumpenkopf', 'motorblock', 'service set', 'serviceset', 'service-set',
|
||||
];
|
||||
|
||||
private const TECHNICAL_PRODUCT_KEYWORDS = [
|
||||
'technisch',
|
||||
'technical',
|
||||
@@ -69,70 +92,6 @@ final class PromptBuilderConfig
|
||||
'ergänzung',
|
||||
'ergaenzung',
|
||||
];
|
||||
private const MAIN_DEVICE_REQUEST_ROLE_KEYWORDS = [
|
||||
'anlage',
|
||||
'messanlage',
|
||||
'gerät',
|
||||
'geraet',
|
||||
'messgerät',
|
||||
'messgeraet',
|
||||
'analysegerät',
|
||||
'analysegeraet',
|
||||
'analysator',
|
||||
'analyzer',
|
||||
'system',
|
||||
'monitor',
|
||||
'controller',
|
||||
'testomat',
|
||||
'pockettester',
|
||||
];
|
||||
|
||||
private const MAIN_DEVICE_PRODUCT_ROLE_KEYWORDS = [
|
||||
'messgerät',
|
||||
'messgeraet',
|
||||
'analysegerät',
|
||||
'analysegeraet',
|
||||
'analysator',
|
||||
'analyzer',
|
||||
'messanlage',
|
||||
'controller',
|
||||
'testomat 2000',
|
||||
'testomat 808',
|
||||
'testomat evo',
|
||||
'pockettester',
|
||||
];
|
||||
|
||||
private const ACCESSORY_PRODUCT_ROLE_KEYWORDS = [
|
||||
'indikator',
|
||||
'indicator',
|
||||
'indikatortyp',
|
||||
'reagenz',
|
||||
'reagent',
|
||||
'reagenzsatz',
|
||||
'kalibrierlösung',
|
||||
'kalibrierloesung',
|
||||
'pufferlösung',
|
||||
'pufferloesung',
|
||||
'reinigungslösung',
|
||||
'reinigungsloesung',
|
||||
'kalibrier',
|
||||
'puffer',
|
||||
'buffer',
|
||||
'zubehör',
|
||||
'zubehor',
|
||||
'accessory',
|
||||
'ersatzteil',
|
||||
'verbrauch',
|
||||
'consumable',
|
||||
'kit',
|
||||
'set',
|
||||
'flasche',
|
||||
'bottle',
|
||||
'100 ml',
|
||||
'500 ml',
|
||||
'100ml',
|
||||
'500ml',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
@@ -357,29 +316,6 @@ final class PromptBuilderConfig
|
||||
return $this->getString('shop_results.exact_product_name_label', 'Exact shop product name');
|
||||
}
|
||||
|
||||
public function getShopRequestedRoleLabel(): string
|
||||
{
|
||||
return $this->getString('shop_results.requested_role_label', 'Requested product role');
|
||||
}
|
||||
|
||||
public function getShopInferredRoleLabel(): string
|
||||
{
|
||||
return $this->getString('shop_results.inferred_role_label', 'Inferred shop product role');
|
||||
}
|
||||
|
||||
public function getShopRoleCompatibilityLabel(): string
|
||||
{
|
||||
return $this->getString('shop_results.role_compatibility_label', 'Role compatibility with request');
|
||||
}
|
||||
|
||||
public function getShopRoleMismatchNotice(): string
|
||||
{
|
||||
return $this->getString(
|
||||
'shop_results.role_mismatch_notice',
|
||||
'Role mismatch: this record is kept only as a separate shop hit; do not use its description, price, URL, or product number as the main device/system answer.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
@@ -497,7 +433,6 @@ final class PromptBuilderConfig
|
||||
'- Keep price, availability, and URL on separate lines when they are present.',
|
||||
'- Only use shop price, URL, product number, or availability for the main product when the shop result clearly matches that same main product.',
|
||||
'- If the matching shop item appears to be an accessory, reagent, consumable, set, or kit, keep it separate and do not present its commercial fields as the main device.',
|
||||
'- If a SHOP PRODUCT RECORD is classified as accessory_or_consumable while the requested product role is main_device_or_system, do not use that record as a product recommendation headline.',
|
||||
'- If the commercial match is uncertain, say that commercial details for the main product are not clearly available in the provided shop results.',
|
||||
'- If no price is shown for a shop item, omit the price instead of writing 0,00 €, free, kostenlos, or a guessed price.',
|
||||
'- For every shop hit shown in the answer, copy the exact shop product name verbatim from the same SHOP PRODUCT RECORD as the item heading.',
|
||||
@@ -607,6 +542,9 @@ final class PromptBuilderConfig
|
||||
'- If the shop match is ambiguous, keep the technical identification and commercial details separate.',
|
||||
'- Shop product names are authoritative for their own shop URL, product number, price, availability, image, description, and metadata.',
|
||||
'- Do not rewrite a shop record heading with a similar device name from retrieved knowledge. If identities differ or are uncertain, separate the RAG device from the shop hit.',
|
||||
'- If the user asks for a main device, measuring device, analyzer, system, or measuring installation, do not present an accessory, indicator, reagent, kit, set, consumable, or service item as the requested main solution.',
|
||||
'- If the only shop hit is role-incompatible with the requested product role, state that no matching main-device shop hit is available in the provided shop data; mention the incompatible hit only as a separate accessory/consumable hit if useful.',
|
||||
'- Never rename a role-incompatible accessory shop record into a main device in headings, summaries, or shop-hit lines.',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -732,6 +670,53 @@ final class PromptBuilderConfig
|
||||
return $this->getString('shop_results.fields.meta_information_label', 'Meta information');
|
||||
}
|
||||
|
||||
public function getShopRequestedRoleLabel(): string
|
||||
{
|
||||
return $this->getString('shop_results.fields.requested_role_label', 'Requested product role');
|
||||
}
|
||||
|
||||
public function getShopInferredRoleLabel(): string
|
||||
{
|
||||
return $this->getString('shop_results.fields.inferred_role_label', 'Inferred shop product role');
|
||||
}
|
||||
|
||||
public function getShopRoleCompatibilityLabel(): string
|
||||
{
|
||||
return $this->getString('shop_results.fields.role_compatibility_label', 'Role compatibility with request');
|
||||
}
|
||||
|
||||
public function getShopRoleIncompatibleCommercialSuppressionNote(): string
|
||||
{
|
||||
return $this->getString(
|
||||
'shop_results.fields.role_incompatible_commercial_suppression_note',
|
||||
'Commercial fields suppressed: this shop record is not a matching main-device result for the requested product role.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getMainDeviceRequestRoleKeywords(): array
|
||||
{
|
||||
return $this->getStringList('role_guard.main_device_request_keywords', self::MAIN_DEVICE_REQUEST_ROLE_KEYWORDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getMainDeviceProductRoleKeywords(): array
|
||||
{
|
||||
return $this->getStringList('role_guard.main_device_product_keywords', self::MAIN_DEVICE_PRODUCT_ROLE_KEYWORDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getAccessoryProductRoleKeywords(): array
|
||||
{
|
||||
return $this->getStringList('role_guard.accessory_product_keywords', self::ACCESSORY_PRODUCT_ROLE_KEYWORDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
@@ -754,39 +739,6 @@ final class PromptBuilderConfig
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getMainDeviceRequestRoleKeywords(): array
|
||||
{
|
||||
return $this->getStringList(
|
||||
'product_roles.main_device_request_keywords',
|
||||
self::MAIN_DEVICE_REQUEST_ROLE_KEYWORDS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getMainDeviceProductRoleKeywords(): array
|
||||
{
|
||||
return $this->getStringList(
|
||||
'product_roles.main_device_product_keywords',
|
||||
self::MAIN_DEVICE_PRODUCT_ROLE_KEYWORDS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getAccessoryProductRoleKeywords(): array
|
||||
{
|
||||
return $this->getStringList(
|
||||
'product_roles.accessory_product_keywords',
|
||||
self::ACCESSORY_PRODUCT_ROLE_KEYWORDS
|
||||
);
|
||||
}
|
||||
|
||||
public function getTechnicalProductModelPattern(): string
|
||||
{
|
||||
return $this->getString('technical_product_model_pattern', '/\b[\p{L}]{2,}\s?\d{2,5}\b/u');
|
||||
|
||||
@@ -201,6 +201,16 @@ final class ShopServiceConfig
|
||||
return $this->int('scores.accessory_query_device_product_bonus', 10);
|
||||
}
|
||||
|
||||
public function shouldFilterAccessoryProductsForDeviceQueries(): bool
|
||||
{
|
||||
return $this->bool('role_guard.filter_accessory_products_for_device_queries', true);
|
||||
}
|
||||
|
||||
public function shouldKeepAmbiguousProductsForDeviceQueries(): bool
|
||||
{
|
||||
return $this->bool('role_guard.keep_ambiguous_products_for_device_queries', true);
|
||||
}
|
||||
|
||||
public function getContainsDigitPattern(): string
|
||||
{
|
||||
return $this->string('patterns.contains_digit', '/\d/u');
|
||||
@@ -343,6 +353,29 @@ final class ShopServiceConfig
|
||||
return $this->string('deduplication.separator', '|');
|
||||
}
|
||||
|
||||
private function bool(string $path, bool $default): bool
|
||||
{
|
||||
$value = $this->value($path, $default);
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
$normalized = strtolower(trim((string) $value));
|
||||
|
||||
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function int(string $path, int $default, int $min = PHP_INT_MIN): int
|
||||
{
|
||||
$value = $this->value($path, $default);
|
||||
|
||||
Reference in New Issue
Block a user