diff --git a/RETRIEX_PRODUCTION_UI_V1_FIX3_CARD_TOGGLE_README.txt b/RETRIEX_PRODUCTION_UI_V1_FIX3_CARD_TOGGLE_README.txt new file mode 100644 index 0000000..30e9bf2 --- /dev/null +++ b/RETRIEX_PRODUCTION_UI_V1_FIX3_CARD_TOGGLE_README.txt @@ -0,0 +1,19 @@ +RetrieX Production UI v1 - Fix 3 Card Toggle + +Ziel: +- Detail-/Meta-/Shopkarten werden nur angezeigt, wenn der Nutzer dies ueber eine Checkbox aktiviert. +- Die Checkbox liegt direkt unter #chat und ist bewusst als Optionsbereich angelegt, damit spaeter weitere Checkboxen ergaenzt werden koennen. + +Geaendert: +- public/index.html: neuer Optionsbereich #retriex-chat-options mit Checkbox #toggle-retriex-cards. +- public/assets/js/base.js: lokale Persistenz der Kartenanzeige per localStorage retriex:showDetailCards. +- public/assets/styles/base.css: Detailkarten werden standardmaessig ausgeblendet und per body-Klasse retriex-show-detail-cards eingeblendet. + +Bewusst nicht geaendert: +- Kein Backend-Eingriff. +- Keine Aenderung an Retrieval, Shop-Suche, Prompting, SSE, Job-Replay oder History. +- Die Karten bleiben im DOM erhalten, werden aber visuell ausgeblendet. Dadurch bleiben Deduplizierung, Stream-Finalisierung und History stabil. + +Hinweis: +- Standard ist aus: Detailkarten sind ausgeblendet. +- Aktiviert der Nutzer die Checkbox, bleibt diese Einstellung im Browser gespeichert. diff --git a/config/retriex/agent.yaml b/config/retriex/agent.yaml index 43cd98b..3d7bd93 100644 --- a/config/retriex/agent.yaml +++ b/config/retriex/agent.yaml @@ -26,6 +26,7 @@ parameters: messages: shop_only: 'Ich finde dazu im RAG-Wissen keine belastbare Fachinformation. Aus den Shopdaten ergeben sich folgende Treffer; technische Eignung bitte prüfen:' shop_with_knowledge: 'Es liegen RAG-/Kontexttreffer und Shopdaten vor. Ohne LLM leite ich daraus keine technische Eignung ab. Die Shopdaten zeigen folgende Treffer; technische Eignung bitte prüfen:' + accessory_only_for_main_device: 'Die Shop-Treffer wirken wie Zubehör/Verbrauchsmaterial und nicht wie eine angefragte Messanlage oder ein Hauptgerät. Ich werte sie deshalb nicht als passende Hauptlösung.' escalation: 'Für eine verbindliche Produktauswahl sollte der konkrete Anwendungsfall durch Vertrieb oder Support geprüft werden.' knowledge_only: 'Ich habe Treffer im RAG-Wissen gefunden, aber ohne LLM kann ich daraus keine belastbare fachliche Antwort synthetisieren. Ich gebe deshalb keine sichere Produktaussage aus. Bitte aktiviere das LLM oder konkretisiere die Frage für eine gezielte Prüfung.' no_data: 'Ich finde dazu keine belastbaren Daten in den vorliegenden Quellen. Bitte nenne Produkt, Messparameter, Zubehör oder Anwendungsfall genauer.' @@ -33,6 +34,50 @@ parameters: no_shop_results_no_knowledge: 'Ich finde weder belastbares RAG-Wissen noch passende Shop-Treffer zur aktuellen Suchanfrage. Das ist keine sichere Negativaussage. Bitte nenne Produkt, Messparameter oder Zubehör konkreter.' shop_unavailable_with_knowledge: 'Live-Shopdaten konnten nicht geladen werden. Ohne Shop-Check treffe ich keine Aussage zu aktueller Verfügbarkeit, Preis oder Shop-Portfolio. Vorhandenes RAG-Wissen darf nur als fachlicher Kontext verstanden werden.' shop_unavailable_no_knowledge: 'Live-Shopdaten konnten nicht geladen werden und ich finde kein belastbares RAG-Wissen. Ich kann daraus keine verlässliche Produkt- oder Verfügbarkeitsaussage ableiten.' + product_roles: + main_device_request_keywords: + - anlage + - messanlage + - gerät + - geraet + - messgerät + - messgeraet + - analysegerät + - analysegeraet + - analysator + - analyzer + - system + - testomat + - pockettester + accessory_product_keywords: + - indikator + - indicator + - indikatortyp + - reagenz + - reagent + - reagenzsatz + - kalibrierlösung + - kalibrierloesung + - pufferlösung + - pufferloesung + - reinigungslösung + - reinigungsloesung + - kalibrier + - puffer + - zubehör + - zubehor + - accessory + - ersatzteil + - verbrauch + - consumable + - kit + - set + - flasche + - bottle + - 100 ml + - 500 ml + - 100ml + - 500ml source_labels: external_url: 'Externe URL' diff --git a/config/retriex/prompt.yaml b/config/retriex/prompt.yaml index e9e8616..01ccf60 100644 --- a/config/retriex/prompt.yaml +++ b/config/retriex/prompt.yaml @@ -27,6 +27,20 @@ parameters: kit, consumable, or service item. - If a shop result has no price field, do not state a price for it. - 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. + - 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. + - 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' + 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: + - '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. fields: product_number_label: Product number @@ -68,6 +82,8 @@ parameters: - '- 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.' - '- 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: - '- 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.' @@ -80,6 +96,7 @@ parameters: - '- 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.' - '- 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.' 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".' @@ -120,10 +137,12 @@ parameters: facts.' - '- 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.' + - '- Never place a shop URL, product number, price, or availability below a different heading taken from retrieved knowledge.' + - '- If technical RAG knowledge and shop records cannot be matched with high confidence, use separate sections: Fachliche Einordnung and Shop-Treffer.' without_shop_rules: - '- If no shop results are present, do not compensate by inventing external products or external manufacturers.' technical_rules: @@ -168,6 +187,7 @@ parameters: - '- 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.' - '- 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 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 @@ -176,6 +196,8 @@ parameters: retrieved knowledge.' - '- Only use commercial fields for the main product when the shop item and the technically identified product clearly refer to the same product identity.' - '- 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 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.' without_shop_rules: @@ -216,6 +238,66 @@ parameters: - '- If a detail is not explicitly stated in the provided sources, say so plainly.' - '- 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.' + 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: source_line: 'Source: Documents' url_content: diff --git a/public/assets/styles/base.css b/public/assets/styles/base.css index d5182eb..ef2abe2 100644 --- a/public/assets/styles/base.css +++ b/public/assets/styles/base.css @@ -94,10 +94,11 @@ input, textarea,select { .message.assistant .bubble { background: #16202f; border: 1px solid var(--border); + max-width: 100%; } .bubble { - max-width: 80%; + max-width: 75%; padding: 0.75rem 1rem; border-radius: 6px; line-height: 1.5; diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index 2bf070e..41420b5 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -1427,6 +1427,7 @@ final readonly class AgentRunner if ($hasShopResults) { return $this->buildNoLlmShopFallbackAnswer( + prompt: $prompt, hasKnowledge: $hasKnowledge, shopResults: $shopResults ); @@ -1453,15 +1454,22 @@ final readonly class AgentRunner /** * @param ShopProductResult[] $shopResults */ - private function buildNoLlmShopFallbackAnswer(bool $hasKnowledge, array $shopResults): string + private function buildNoLlmShopFallbackAnswer(string $prompt, bool $hasKnowledge, array $shopResults): string { $intro = $hasKnowledge ? $this->agentRunnerConfig->getNoLlmFallbackShopWithKnowledgeMessage() : $this->agentRunnerConfig->getNoLlmFallbackShopOnlyMessage(); + $requestedProductRole = $this->resolveNoLlmRequestedProductRole($prompt); - $lines = [$intro, '']; + $lines = [$intro]; - foreach ($this->buildNoLlmShopProductLines($shopResults) as $line) { + if ($this->hasOnlyNoLlmAccessoryResultsForMainDeviceRequest($requestedProductRole, $shopResults)) { + $lines[] = $this->agentRunnerConfig->getNoLlmFallbackAccessoryOnlyForMainDeviceMessage(); + } + + $lines[] = ''; + + foreach ($this->buildNoLlmShopProductLines($shopResults, $requestedProductRole) as $line) { $lines[] = $line; } @@ -1499,7 +1507,7 @@ final readonly class AgentRunner * @param ShopProductResult[] $shopResults * @return string[] */ - private function buildNoLlmShopProductLines(array $shopResults): array + private function buildNoLlmShopProductLines(array $shopResults, string $requestedProductRole): array { $maxResults = max(1, $this->agentRunnerConfig->getNoLlmFallbackMaxShopResults()); $lines = []; @@ -1510,7 +1518,7 @@ final readonly class AgentRunner continue; } - $lines[] = $this->formatNoLlmShopProductLine($product, $index); + $lines[] = $this->formatNoLlmShopProductLine($product, $index, $requestedProductRole); $index++; if (count($lines) >= $maxResults) { @@ -1525,9 +1533,10 @@ final readonly class AgentRunner return $lines; } - private function formatNoLlmShopProductLine(ShopProductResult $product, int $index): string + private function formatNoLlmShopProductLine(ShopProductResult $product, int $index, string $requestedProductRole): string { $parts = []; + $productRole = $this->resolveNoLlmShopProductRole($product); $name = $this->normalizeOneLine($product->name); $parts[] = $name !== '' ? $name : 'Unbenanntes Shop-Produkt'; @@ -1552,9 +1561,86 @@ final readonly class AgentRunner $parts[] = 'URL: ' . $this->normalizeOneLine($product->url); } + if ($requestedProductRole === 'main_device_or_system' && $productRole === 'accessory_or_consumable') { + $parts[] = 'Hinweis: Zubehör/Verbrauchsartikel; nicht als Messanlage/Gerät bestätigt'; + } + return sprintf('%d. %s', $index, implode(' | ', $parts)); } + /** + * @param ShopProductResult[] $shopResults + */ + private function hasOnlyNoLlmAccessoryResultsForMainDeviceRequest(string $requestedProductRole, array $shopResults): bool + { + if ($requestedProductRole !== 'main_device_or_system') { + return false; + } + + $seenProducts = 0; + + foreach ($shopResults as $product) { + if (!$product instanceof ShopProductResult) { + continue; + } + + $seenProducts++; + + if ($this->resolveNoLlmShopProductRole($product) !== 'accessory_or_consumable') { + return false; + } + } + + return $seenProducts > 0; + } + + private function resolveNoLlmRequestedProductRole(string $prompt): string + { + $normalized = mb_strtolower($prompt, 'UTF-8'); + + if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords())) { + return 'accessory_or_consumable'; + } + + if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords())) { + return 'main_device_or_system'; + } + + return 'unknown'; + } + + private function resolveNoLlmShopProductRole(ShopProductResult $product): string + { + $normalized = mb_strtolower($this->normalizeOneLine(implode(' ', [ + $product->name, + (string) $product->description, + (string) $product->customFields, + implode(' ', $product->highlights), + ])), 'UTF-8'); + + if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords())) { + return 'accessory_or_consumable'; + } + + return 'unknown'; + } + + /** + * @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; + } + /** * @param string[] $sources diff --git a/src/Agent/PromptBuilder.php b/src/Agent/PromptBuilder.php index b02b105..0c4cb9d 100644 --- a/src/Agent/PromptBuilder.php +++ b/src/Agent/PromptBuilder.php @@ -52,6 +52,7 @@ 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, @@ -60,7 +61,11 @@ final readonly class PromptBuilder ); $systemBlock = $this->buildSystemBlock(); - $shopBlock = $this->buildShopBlock($shopResults, $swagFullOutPut); + $shopBlock = $this->buildShopBlock( + shopResults: $shopResults, + swagFullOutPut: $swagFullOutPut, + requestedProductRole: $requestedProductRole + ); $outputPriorityBlock = $this->buildOutputPriorityBlock( hasShopResults: $hasShopResults, isTechnicalProductQuestion: $isTechnicalProductQuestion @@ -178,7 +183,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 + private function buildShopBlock(array $shopResults, ?string $swagFullOutPut, string $requestedProductRole): string { $parts = []; @@ -208,7 +213,8 @@ final readonly class PromptBuilder $lines[] = $this->buildShopProductEntry( product: $product, index: $i + 1, - isDetailed: $isDetailed + isDetailed: $isDetailed, + requestedProductRole: $requestedProductRole ); } @@ -464,12 +470,34 @@ final readonly class PromptBuilder return $rules; } - private function buildShopProductEntry(ShopProductResult $product, int $index, bool $isDetailed): string - { + private function buildShopProductEntry( + ShopProductResult $product, + int $index, + bool $isDetailed, + string $requestedProductRole + ): 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'; + $entryParts = [ - "[{$index}] " . $this->normalizeBlockText($product->name), + sprintf($this->config->getShopRecordHeaderTemplate(), $index), + $this->config->getShopExactProductNameLabel() . ': ' . $productName, + $this->config->getShopRequestedRoleLabel() . ': ' . $requestedProductRole, + $this->config->getShopInferredRoleLabel() . ': ' . $productRole, + $this->config->getShopRoleCompatibilityLabel() . ': ' . $roleCompatibility, ]; + foreach ($this->config->getShopAtomicRecordNoteLines() as $noteLine) { + $noteLine = $this->normalizeBlockText($noteLine); + + if ($noteLine !== '') { + $entryParts[] = $noteLine; + } + } + if ($product->productNumber) { $entryParts[] = $this->config->getShopProductNumberLabel() . ': ' . $this->normalizeBlockText($product->productNumber); @@ -492,12 +520,16 @@ final readonly class PromptBuilder : $this->config->getShopAvailabilityNoLabel()); } - foreach ($product->highlights as $highlight) { - $highlight = $this->normalizeBlockText((string) $highlight); + if (!$isMainDeviceRequestAccessoryMismatch) { + 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) { @@ -510,12 +542,12 @@ final readonly class PromptBuilder . $this->normalizeBlockText($product->productImage); } - if ($isDetailed && $product->description) { + if (!$isMainDeviceRequestAccessoryMismatch && $isDetailed && $product->description) { $entryParts[] = $this->config->getShopDescriptionLabel() . ': ' . $this->normalizeBlockText($product->description); } - if ($product->customFields) { + if (!$isMainDeviceRequestAccessoryMismatch && $product->customFields) { $entryParts[] = $this->config->getShopMetaInformationLabel() . ': ' . $this->normalizeBlockText($product->customFields); } @@ -596,6 +628,81 @@ 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'); diff --git a/src/Config/AgentRunnerConfig.php b/src/Config/AgentRunnerConfig.php index cf00f97..b381a17 100644 --- a/src/Config/AgentRunnerConfig.php +++ b/src/Config/AgentRunnerConfig.php @@ -6,6 +6,53 @@ namespace App\Config; final class AgentRunnerConfig { + private const NO_LLM_MAIN_DEVICE_REQUEST_ROLE_KEYWORDS = [ + 'anlage', + 'messanlage', + 'gerät', + 'geraet', + 'messgerät', + 'messgeraet', + 'analysegerät', + 'analysegeraet', + 'analysator', + 'analyzer', + 'system', + 'testomat', + 'pockettester', + ]; + + private const NO_LLM_ACCESSORY_PRODUCT_ROLE_KEYWORDS = [ + 'indikator', + 'indicator', + 'indikatortyp', + 'reagenz', + 'reagent', + 'reagenzsatz', + 'kalibrierlösung', + 'kalibrierloesung', + 'pufferlösung', + 'pufferloesung', + 'reinigungslösung', + 'reinigungsloesung', + 'kalibrier', + 'puffer', + 'zubehör', + 'zubehor', + 'accessory', + 'ersatzteil', + 'verbrauch', + 'consumable', + 'kit', + 'set', + 'flasche', + 'bottle', + '100 ml', + '500 ml', + '100ml', + '500ml', + ]; + /** * @param array $config */ @@ -194,6 +241,14 @@ final class AgentRunnerConfig ); } + public function getNoLlmFallbackAccessoryOnlyForMainDeviceMessage(): string + { + return $this->getString( + 'no_llm_fallback.messages.accessory_only_for_main_device', + 'Die Shop-Treffer wirken wie Zubehör/Verbrauchsmaterial und nicht wie eine angefragte Messanlage oder ein Hauptgerät. Ich werte sie deshalb nicht als passende Hauptlösung.' + ); + } + public function getNoLlmFallbackEscalationMessage(): string { return $this->getString( @@ -234,6 +289,28 @@ final class AgentRunnerConfig ); } + /** + * @return string[] + */ + public function getNoLlmMainDeviceRequestRoleKeywords(): array + { + return $this->getStringList( + 'no_llm_fallback.product_roles.main_device_request_keywords', + self::NO_LLM_MAIN_DEVICE_REQUEST_ROLE_KEYWORDS + ); + } + + /** + * @return string[] + */ + public function getNoLlmAccessoryProductRoleKeywords(): array + { + return $this->getStringList( + 'no_llm_fallback.product_roles.accessory_product_keywords', + self::NO_LLM_ACCESSORY_PRODUCT_ROLE_KEYWORDS + ); + } + public function getNoLlmFallbackShopUnavailableWithKnowledgeMessage(): string { return $this->getString( diff --git a/src/Config/PromptBuilderConfig.php b/src/Config/PromptBuilderConfig.php index ec578a3..f72cac9 100644 --- a/src/Config/PromptBuilderConfig.php +++ b/src/Config/PromptBuilderConfig.php @@ -272,6 +272,9 @@ final class PromptBuilderConfig 'Do not merge a device identified in retrieved knowledge with price, URL, product number, or availability from a different shop item such as a reagent, accessory, kit, consumable, or service item.', 'If a shop result has no price field, do not state a price for it.', '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.', + '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.', + '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.', ]); } @@ -280,6 +283,49 @@ final class PromptBuilderConfig return $this->getString('shop_results.overflow_notice_template', 'Only the top %d ranked shop results are shown here out of %d total results.'); } + public function getShopRecordHeaderTemplate(): string + { + return $this->getString('shop_results.record_header_template', '[%d] SHOP PRODUCT RECORD'); + } + + public function getShopExactProductNameLabel(): string + { + 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[] + */ + public function getShopAtomicRecordNoteLines(): array + { + return $this->getStringList('shop_results.atomic_record_note_lines', [ + 'Record boundary: all fields below belong only to this exact shop product record.', + ]); + } + public function getOutputPrioritySectionLabel(): string { return $this->getString('sections.output_priority_label', 'OUTPUT PRIORITY'); @@ -387,8 +433,12 @@ 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.', + '- Never place a shop URL, product number, price, or availability below a different heading taken from retrieved knowledge.', + '- If technical RAG knowledge and shop records cannot be matched with high confidence, use separate sections: Fachliche Einordnung and Shop-Treffer.', ]); } @@ -491,6 +541,8 @@ final class PromptBuilderConfig '- Do not assign the product number, price, URL, or availability of a reagent, accessory, kit, set, consumable, or service item to a device identified in retrieved knowledge.', '- Only use commercial fields for the main product when the shop item and the technically identified product clearly refer to the same product identity.', '- 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.', ]); } @@ -638,6 +690,39 @@ 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');