optimize technical truth

This commit is contained in:
team 1
2026-04-28 17:00:12 +02:00
parent 8d9f863143
commit bca015129c
5 changed files with 471 additions and 314 deletions

View File

@@ -29,16 +29,10 @@ parameters:
- Never interpret a missing price or a zero price as free, kostenlos, gratis, or available for 0.00 EUR. - Never interpret a missing price or a zero price as free, kostenlos, gratis, or available for 0.00 EUR.
- Treat every SHOP PRODUCT RECORD as atomic: exact product name, product number, price, availability, URL, image, description, and metadata must stay together. - Treat every SHOP PRODUCT RECORD as atomic: exact product name, product number, price, availability, URL, image, description, and metadata must stay together.
- When outputting a shop item, use the exact shop product name from that same SHOP PRODUCT RECORD as the heading. Never use a retrieved-knowledge device name as the heading for a different shop URL or product number. - When outputting a shop item, use the exact shop product name from that same SHOP PRODUCT RECORD as the heading. Never use a retrieved-knowledge device name as the heading for a different shop URL or product number.
- Product name and URL define the primary identity of a shop record. Descriptions may mention compatible devices but must not turn an accessory, indicator, reagent, kit, set, or consumable into a main-device shop hit.
- If a technical device from retrieved knowledge and a shop record are not clearly the same exact product identity, separate Fachliche Einordnung from Shop-Treffer instead of merging them. - If a technical device from retrieved knowledge and a shop record are not clearly the same exact product identity, separate Fachliche Einordnung from Shop-Treffer instead of merging them.
- Use the product role fields in each SHOP PRODUCT RECORD. If the user asks for a main device/system, a record classified as accessory_or_consumable is not a primary product candidate.
- For main-device/system requests, do not let a single accessory, reagent, indicator, kit, set, or consumable shop hit dominate the answer. Mention it only as a separate non-primary shop hit if useful.
- If a shop record says Role compatibility with request is not compatible, do not use its description, price, URL, or product number as evidence for the requested main device/system.
record_header_template: '[%d] SHOP PRODUCT RECORD' record_header_template: '[%d] SHOP PRODUCT RECORD'
exact_product_name_label: Exact shop product name exact_product_name_label: Exact shop product name
requested_role_label: Requested product role
inferred_role_label: Inferred shop product role
role_compatibility_label: Role compatibility with request
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.'
atomic_record_note_lines: atomic_record_note_lines:
- 'Record boundary: all fields below belong only to this exact shop product record.' - 'Record boundary: all fields below belong only to this exact shop product record.'
overflow_notice_template: Only the top %d ranked shop results are shown here out of %d total results. overflow_notice_template: Only the top %d ranked shop results are shown here out of %d total results.
@@ -54,6 +48,10 @@ parameters:
product_image_label: Product image product_image_label: Product image
description_label: Description description_label: Description
meta_information_label: Meta information meta_information_label: Meta information
requested_role_label: Requested product role
inferred_role_label: Inferred shop product role
role_compatibility_label: Role compatibility with request
role_incompatible_commercial_suppression_note: 'Commercial fields suppressed: this shop record is not a matching main-device result for the requested product role.'
technical_product_keyword_match_threshold: 2 technical_product_keyword_match_threshold: 2
sections: sections:
system_label: SYSTEM system_label: SYSTEM
@@ -76,14 +74,74 @@ parameters:
- Conversation context must not override retrieved factual knowledge or live shop data. - Conversation context must not override retrieved factual knowledge or live shop data.
shop_search: shop_search:
source_line: 'Source: Shop Search' source_line: 'Source: Shop Search'
role_guard:
main_device_request_keywords:
- messanlage
- messanlagen
- anlage
- anlagen
- messgerät
- messgeraet
- analysegerät
- analysegeraet
- analysator
- analyzer
- gerät
- geraet
- system
- monitor
- controller
main_device_product_keywords:
- messanlage
- messanlagen
- messgerät
- messgeraet
- analysegerät
- analysegeraet
- analysator
- analyzer
- online-analysator
- online analysegerät
- gerät
- geraet
- system
- monitor
- controller
accessory_product_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
- filter
- pumpenkopf
- motorblock
- service set
- serviceset
- service-set
output_priority: output_priority:
rules: rules:
- '- Use retrieved knowledge first to determine the technically matching product or answer.' - '- Use retrieved knowledge first to determine the technically matching product or answer.'
- '- For product-selection questions such as which device can measure or monitor a parameter, use relevant live shop results as a fallback when retrieved knowledge does not identify a matching product.' - '- For product-selection questions such as which device can measure or monitor a parameter, use relevant live shop results as a fallback when retrieved knowledge does not identify a matching product.'
- '- If shop results are present, use them afterwards to add current price, availability, and the actual URL.' - '- If shop results are present, use them afterwards to add current price, availability, and the actual URL.'
- '- Do not let bundles, accessories, or service items override a better technical match unless the user explicitly asks for them.' - '- Do not let bundles, accessories, or service items override a better technical match unless the user explicitly asks for them.'
- '- For requests asking for a main device, measuring device, measuring installation, analyzer, system, or plant, never make an accessory/consumable shop record the headline recommendation.'
- '- If all shop hits are accessory/consumable records while the user asked for a main device/system, answer from retrieved technical knowledge first and say that the shop hits do not clearly contain a matching main device.'
technical_rules: technical_rules:
- '- For technical questions, answer the exact requested fact first and keep it as the main answer.' - '- For technical questions, answer the exact requested fact first and keep it as the main answer.'
- '- If one source chunk contains both the best matching value and nearby comparison values, use the nearby values only as context and do not include them unless the user asks for comparison or alternatives.' - '- If one source chunk contains both the best matching value and nearby comparison values, use the nearby values only as context and do not include them unless the user asks for comparison or alternatives.'
@@ -96,7 +154,6 @@ parameters:
- '- Never present missing or weak evidence as proof that a product, value, accessory, or suitability does not exist.' - '- Never present missing or weak evidence as proof that a product, value, accessory, or suitability does not exist.'
- '- A negative answer is allowed only when the provided sources explicitly support that negative finding for the asked scope.' - '- A negative answer is allowed only when the provided sources explicitly support that negative finding for the asked scope.'
- '- If several products, parameters, or accessories could match, ask one focused clarification question instead of guessing.' - '- If several products, parameters, or accessories could match, ask one focused clarification question instead of guessing.'
- '- If the retrieved technical answer identifies devices but shop data only contains accessory/consumable hits, do not present those shop hits as the answer to a device/system request.'
- '- For risky or binding product selection, state that sales or support should verify the application before a final selection.' - '- For risky or binding product selection, state that sales or support should verify the application before a final selection.'
without_shop_check_rules: without_shop_check_rules:
- '- If the question is product-related and no live shop check was performed in this run, do not make a portfolio-wide negative statement such as "there is no product".' - '- If the question is product-related and no live shop check was performed in this run, do not make a portfolio-wide negative statement such as "there is no product".'
@@ -137,7 +194,8 @@ parameters:
facts.' facts.'
- '- Keep price, availability, and URL on separate lines when they are present.' - '- 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.' - '- Only use shop price, URL, product number, or availability for the main product when the shop result clearly matches that same main product.'
- '- 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 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 the commercial match is uncertain, say that commercial details for the main product are not clearly available in the provided shop results.' - '- 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.' - '- 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.' - '- 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.'
@@ -187,7 +245,6 @@ parameters:
- '- When shop results are present and relevant, include current price and the actual URL if available.' - '- When shop results are present and relevant, include current price and the actual URL if available.'
- '- If the shop data does not provide a positive price for a result, do not output any price for that result.' - '- If the shop data does not provide a positive price for a result, do not output any price for that result.'
- '- Do not let accessories, bundles, or service items override a technically better product match unless the user explicitly asks for them.' - '- Do not let accessories, bundles, or service items override a technically better product match unless the user explicitly asks for them.'
- '- If the user asks for a main device/system and the only shop hit is an accessory, reagent, indicator, kit, set, or consumable, state that the shop data does not clearly provide a matching main device/system.'
- '- Do not call accessories, indicators, reagents, kits, sets, or consumables a device, measuring device, or main product unless the source explicitly says - '- Do not call accessories, indicators, reagents, kits, sets, or consumables a device, measuring device, or main product unless the source explicitly says
so.' so.'
- '- Do not claim that an accessory is required, necessary, used for calibration, or sets the measurement range unless this is explicitly stated in the provided - '- Do not claim that an accessory is required, necessary, used for calibration, or sets the measurement range unless this is explicitly stated in the provided
@@ -198,6 +255,11 @@ parameters:
- '- If the shop match is ambiguous, keep the technical identification and commercial details separate.' - '- 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.' - '- 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.' - '- 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.'
- '- If a SHOP PRODUCT RECORD says Commercial fields suppressed, do not output its price, availability, URL, product number, image, or metadata anywhere in the answer.'
- '- Never write shop-hit lines such as price, availability, URL, product number, or Shop-Treffer below a RAG device unless the same exact SHOP PRODUCT RECORD names that device as the exact shop product.'
- '- Never rename a role-incompatible accessory shop record into a main device in headings, summaries, or shop-hit lines.'
- '- If the user asks for the price or availability of a referenced accessory, indicator, reagent, kit, set, or consumable, use commercial fields only from a shop result that clearly matches that accessory identity and code.' - '- If the user asks for the price or availability of a referenced accessory, indicator, reagent, kit, set, or consumable, use commercial fields only from a shop result that clearly matches that accessory identity and code.'
- '- For such accessory price follow-ups, do not answer with the price, URL, product number, or availability of the main device or of unrelated reagents; if no matching accessory shop item is present, say that the price is not available in the provided shop data.' - '- For such accessory price follow-ups, do not answer with the price, URL, product number, or availability of the main device or of unrelated reagents; if no matching accessory shop item is present, say that the price is not available in the provided shop data.'
without_shop_rules: without_shop_rules:
@@ -238,66 +300,6 @@ parameters:
- '- If a detail is not explicitly stated in the provided sources, say so plainly.' - '- If a detail is not explicitly stated in the provided sources, say so plainly.'
- '- Prefer short, source-close sentences over explanatory expansion.' - '- Prefer short, source-close sentences over explanatory expansion.'
- '- If the sources only support that a product family is not suitable, output only that unsuitability and stop there.' - '- If the sources only support that a product family is not suitable, output only that unsuitability and stop there.'
product_roles:
main_device_request_keywords:
- anlage
- messanlage
- gerät
- geraet
- messgerät
- messgeraet
- analysegerät
- analysegeraet
- analysator
- analyzer
- system
- monitor
- controller
- testomat
- pockettester
main_device_product_keywords:
- messgerät
- messgeraet
- analysegerät
- analysegeraet
- analysator
- analyzer
- messanlage
- controller
- testomat 2000
- testomat 808
- testomat evo
- pockettester
accessory_product_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
retrieved_knowledge: retrieved_knowledge:
source_line: 'Source: Documents' source_line: 'Source: Documents'
url_content: url_content:

View File

@@ -52,7 +52,6 @@ final readonly class PromptBuilder
$hasKnowledge = $knowledgeChunks !== [] || $urlContent !== ''; $hasKnowledge = $knowledgeChunks !== [] || $urlContent !== '';
$isTechnicalProductQuestion = $this->isLikelyTechnicalProductQuestion($prompt); $isTechnicalProductQuestion = $this->isLikelyTechnicalProductQuestion($prompt);
$asksForAccessoryOrBundle = $this->asksForAccessoryOrBundle($prompt); $asksForAccessoryOrBundle = $this->asksForAccessoryOrBundle($prompt);
$requestedProductRole = $this->resolveRequestedProductRole($prompt);
$reliabilityState = $this->resolveReliabilityState( $reliabilityState = $this->resolveReliabilityState(
hasKnowledge: $hasKnowledge, hasKnowledge: $hasKnowledge,
hasShopResults: $hasShopResults, hasShopResults: $hasShopResults,
@@ -61,11 +60,7 @@ final readonly class PromptBuilder
); );
$systemBlock = $this->buildSystemBlock(); $systemBlock = $this->buildSystemBlock();
$shopBlock = $this->buildShopBlock( $shopBlock = $this->buildShopBlock($prompt, $shopResults, $swagFullOutPut);
shopResults: $shopResults,
swagFullOutPut: $swagFullOutPut,
requestedProductRole: $requestedProductRole
);
$outputPriorityBlock = $this->buildOutputPriorityBlock( $outputPriorityBlock = $this->buildOutputPriorityBlock(
hasShopResults: $hasShopResults, hasShopResults: $hasShopResults,
isTechnicalProductQuestion: $isTechnicalProductQuestion isTechnicalProductQuestion: $isTechnicalProductQuestion
@@ -183,7 +178,7 @@ final readonly class PromptBuilder
* Shop data is the most current source for commercial details. * Shop data is the most current source for commercial details.
* It should not override technical matching logic. * 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 = []; $parts = [];
@@ -214,7 +209,7 @@ final readonly class PromptBuilder
product: $product, product: $product,
index: $i + 1, index: $i + 1,
isDetailed: $isDetailed, isDetailed: $isDetailed,
requestedProductRole: $requestedProductRole requestedRole: $this->resolveRequestedProductRole($prompt)
); );
} }
@@ -377,7 +372,7 @@ final readonly class PromptBuilder
} }
$n = $i + 1; $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 !== []) { if ($lines !== []) {
@@ -470,23 +465,18 @@ final readonly class PromptBuilder
return $rules; return $rules;
} }
private function buildShopProductEntry( private function buildShopProductEntry(ShopProductResult $product, int $index, bool $isDetailed, string $requestedRole): string
ShopProductResult $product, {
int $index,
bool $isDetailed,
string $requestedProductRole
): string {
$productName = $this->normalizeBlockText($product->name); $productName = $this->normalizeBlockText($product->name);
$productRole = $this->resolveShopProductRole($product);
$roleCompatibility = $this->resolveShopProductRoleCompatibility($requestedProductRole, $productRole); $inferredRole = $this->resolveShopProductRole($product);
$isMainDeviceRequestAccessoryMismatch = $requestedProductRole === 'main_device_or_system' $roleCompatibility = $this->resolveShopRoleCompatibility($requestedRole, $inferredRole);
&& $productRole === 'accessory_or_consumable';
$entryParts = [ $entryParts = [
sprintf($this->config->getShopRecordHeaderTemplate(), $index), sprintf($this->config->getShopRecordHeaderTemplate(), $index),
$this->config->getShopExactProductNameLabel() . ': ' . $productName, $this->config->getShopExactProductNameLabel() . ': ' . $productName,
$this->config->getShopRequestedRoleLabel() . ': ' . $requestedProductRole, $this->config->getShopRequestedRoleLabel() . ': ' . $requestedRole,
$this->config->getShopInferredRoleLabel() . ': ' . $productRole, $this->config->getShopInferredRoleLabel() . ': ' . $inferredRole,
$this->config->getShopRoleCompatibilityLabel() . ': ' . $roleCompatibility, $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() . ': ' $entryParts[] = $this->config->getShopProductNumberLabel() . ': '
. $this->normalizeBlockText($product->productNumber); . $this->normalizeBlockText($product->productNumber);
} }
if ($product->manufacturer) { if (!$suppressCommercialFields && $product->manufacturer) {
$entryParts[] = $this->config->getShopManufacturerLabel() . ': ' $entryParts[] = $this->config->getShopManufacturerLabel() . ': '
. $this->normalizeBlockText($product->manufacturer); . $this->normalizeBlockText($product->manufacturer);
} }
if ($product->price) { if (!$suppressCommercialFields && $product->price) {
$entryParts[] = $this->config->getShopPriceLabel() . ': ' $entryParts[] = $this->config->getShopPriceLabel() . ': '
. $this->normalizeBlockText($product->price); . $this->normalizeBlockText($product->price);
} }
if ($product->available !== null) { if (!$suppressCommercialFields && $product->available !== null) {
$entryParts[] = $this->config->getShopAvailabilityLabel() . ': ' $entryParts[] = $this->config->getShopAvailabilityLabel() . ': '
. ($product->available . ($product->available
? $this->config->getShopAvailabilityYesLabel() ? $this->config->getShopAvailabilityYesLabel()
: $this->config->getShopAvailabilityNoLabel()); : $this->config->getShopAvailabilityNoLabel());
} }
if (!$isMainDeviceRequestAccessoryMismatch) { foreach ($product->highlights as $highlight) {
foreach ($product->highlights as $highlight) { $highlight = $this->normalizeBlockText((string) $highlight);
$highlight = $this->normalizeBlockText((string) $highlight);
if ($highlight !== '') { if ($highlight !== '') {
$entryParts[] = $this->config->getShopHighlightPrefix() . $highlight; $entryParts[] = $this->config->getShopHighlightPrefix() . $highlight;
}
} }
} else {
$entryParts[] = $this->config->getShopRoleMismatchNotice();
} }
if ($product->url) { if (!$suppressCommercialFields && $product->url) {
$entryParts[] = $this->config->getShopUrlLabel() . ': ' $entryParts[] = $this->config->getShopUrlLabel() . ': '
. $this->normalizeBlockText($product->url); . $this->normalizeBlockText($product->url);
} }
if ($product->productImage) { if (!$suppressCommercialFields && $product->productImage) {
$entryParts[] = $this->config->getShopProductImageLabel() . ': ' $entryParts[] = $this->config->getShopProductImageLabel() . ': '
. $this->normalizeBlockText($product->productImage); . $this->normalizeBlockText($product->productImage);
} }
if (!$isMainDeviceRequestAccessoryMismatch && $isDetailed && $product->description) { if (!$suppressCommercialFields && $isDetailed && $product->description) {
$entryParts[] = $this->config->getShopDescriptionLabel() . ': ' $entryParts[] = $this->config->getShopDescriptionLabel() . ': '
. $this->normalizeBlockText($product->description); . $this->normalizeBlockText($product->description);
} }
if (!$isMainDeviceRequestAccessoryMismatch && $product->customFields) { if (!$suppressCommercialFields && $product->customFields) {
$entryParts[] = $this->config->getShopMetaInformationLabel() . ': ' $entryParts[] = $this->config->getShopMetaInformationLabel() . ': '
. $this->normalizeBlockText($product->customFields); . $this->normalizeBlockText($product->customFields);
} }
@@ -604,6 +597,123 @@ final readonly class PromptBuilder
return implode("\n\n", $filtered); 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 private function normalizeNullableBlockText(?string $value): ?string
{ {
if ($value === null) { if ($value === null) {
@@ -628,81 +738,6 @@ final readonly class PromptBuilder
return $value; 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 private function isLikelyTechnicalProductQuestion(string $prompt): bool
{ {
$normalized = mb_strtolower($prompt, 'UTF-8'); $normalized = mb_strtolower($prompt, 'UTF-8');

View File

@@ -175,6 +175,12 @@ final class ShopSearchService
referenceContext: $referenceContext referenceContext: $referenceContext
); );
$finalProducts = $this->applyRoleGuardrails(
products: $finalProducts,
query: $primaryQuery,
originalPrompt: $originalPrompt
);
$finalProducts = $this->applyPriceFilters( $finalProducts = $this->applyPriceFilters(
products: $finalProducts, products: $finalProducts,
query: $primaryQuery query: $primaryQuery
@@ -984,6 +990,69 @@ final class ShopSearchService
return false; 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 * @param string[] $focusTerms
*/ */
@@ -1261,23 +1330,89 @@ final class ShopSearchService
private function isAccessoryLikeProduct(ShopProductResult $product): bool private function isAccessoryLikeProduct(ShopProductResult $product): bool
{ {
$corpus = $this->buildNormalizedProductCorpus($product); $primaryRole = $this->resolvePrimaryShopProductRole($product);
foreach ($this->shopConfig->getAccessoryProductKeywords() as $keyword) { if ($primaryRole === 'accessory_or_consumable') {
if (str_contains($corpus, $this->normalizeForMatching($keyword))) { return true;
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 private function isDeviceLikeProduct(ShopProductResult $product): bool
{ {
$corpus = $this->buildNormalizedProductCorpus($product); $primaryRole = $this->resolvePrimaryShopProductRole($product);
foreach ($this->shopConfig->getDeviceProductKeywords() as $keyword) { if ($primaryRole === 'main_device') {
if (str_contains($corpus, $this->normalizeForMatching($keyword))) { 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; return true;
} }
} }

View File

@@ -6,6 +6,29 @@ namespace App\Config;
final class PromptBuilderConfig 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 = [ private const TECHNICAL_PRODUCT_KEYWORDS = [
'technisch', 'technisch',
'technical', 'technical',
@@ -69,70 +92,6 @@ final class PromptBuilderConfig
'ergänzung', 'ergänzung',
'ergaenzung', '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 * @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'); 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[] * @return string[]
*/ */
@@ -497,7 +433,6 @@ final class PromptBuilderConfig
'- Keep price, availability, and URL on separate lines when they are present.', '- 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.', '- 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 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 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.', '- 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.', '- 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.', '- 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.', '- 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.', '- 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'); 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[] * @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 public function getTechnicalProductModelPattern(): string
{ {
return $this->getString('technical_product_model_pattern', '/\b[\p{L}]{2,}\s?\d{2,5}\b/u'); return $this->getString('technical_product_model_pattern', '/\b[\p{L}]{2,}\s?\d{2,5}\b/u');

View File

@@ -201,6 +201,16 @@ final class ShopServiceConfig
return $this->int('scores.accessory_query_device_product_bonus', 10); 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 public function getContainsDigitPattern(): string
{ {
return $this->string('patterns.contains_digit', '/\d/u'); return $this->string('patterns.contains_digit', '/\d/u');
@@ -343,6 +353,29 @@ final class ShopServiceConfig
return $this->string('deduplication.separator', '|'); 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 private function int(string $path, int $default, int $min = PHP_INT_MIN): int
{ {
$value = $this->value($path, $default); $value = $this->value($path, $default);