diff --git a/config/retriex/agent.yaml b/config/retriex/agent.yaml index 51f167a..5a93e6a 100644 --- a/config/retriex/agent.yaml +++ b/config/retriex/agent.yaml @@ -175,6 +175,8 @@ parameters: allowed_terms: [] blocked_terms: [] code_patterns: [] + adjacent_variant_patterns: [] + adjacent_variant_terms: [] attribute_cleanup: diff --git a/config/retriex/chat-messages.yaml b/config/retriex/chat-messages.yaml index be0b536..cd66a21 100644 --- a/config/retriex/chat-messages.yaml +++ b/config/retriex/chat-messages.yaml @@ -241,34 +241,20 @@ parameters: action_type: shop_search shop_results: - label: Preis anzeigen - prompt: Zeige mir die Preise zu {shop_query}. + prompt: Zeige mir die Preise zu {shop_price_query}. action_type: price_details hide_when_answer_matches_any: - - '/\bkeine?\s+(?:passende[nrs]?\s+)?(?:produktbezeichnung|shop-?treffer|treffer|produkte?)\b/iu' - - '/\bkeine?\b.{0,80}\b(?:explizit|direkt|passende[nrs]?|exakte[nrs]?)\b.{0,80}\b(?:produktbezeichnung|produktreihe|produktnummer|produkte?|shop-?treffer|treffer)\b/iu' - - '/\b(?:technische\s+eignung\s+nicht\s+sicher\s+belegt|ohne\s+technische[nr]?\s+eignung(?:snachweis)?|keine?\b.{0,80}\btechnische[nr]?\s+eignung)\b/iu' - - '/\b(?:preis|preise|kosten)\b.{0,140}\b(?:nicht\s+(?:explizit\s+)?(?:angegeben|enthalten|verfuegbar|verfügbar|bekannt|ausgewiesen)|nur\s+auf\s+anfrage|auf\s+anfrage|keine?\s+preis(?:angabe|information|e)?)\b/iu' - - '/\b(?:exakte[nrs]?|konkrete[nrs]?|aktuelle[nrs]?)\s+preis\b.{0,140}\b(?:nicht|muesste|müsste|anfrage|abrufen|angefragt)\b/iu' + - '/\b(?:preis(?:angabe|information|e)?|preise?)\b.{0,100}\b(?:nicht|kein(?:e|en)?|ohne)\b.{0,100}\b(?:angegeben|vorhanden|enthalten|ausgewiesen|gefunden|verfügbar|verfuegbar)\b/iu' + - '/\b(?:nicht|kein(?:e|en)?|ohne)\b.{0,100}\b(?:preis(?:angabe|information|e)?|preise?)\b/iu' + - '/\bpreis(?:angabe|information|e)?\b.{0,100}\bauf anfrage\b/iu' - label: Nur Zubehör anzeigen prompt: Suche im Shop nach {shop_query} und zeige daraus nur Zubehör. action_type: role_filter target_role: accessory_or_consumable - hide_when_answer_matches_any: - - '/\bkeine?\s+(?:passende[nrs]?\s+)?(?:produktbezeichnung|shop-?treffer|treffer|produkte?)\b/iu' - - '/\bkeine?\b.{0,80}\b(?:explizit|direkt|passende[nrs]?|exakte[nrs]?)\b.{0,80}\b(?:produktbezeichnung|produktreihe|produktnummer|produkte?|shop-?treffer|treffer)\b/iu' - - '/\b(?:technische\s+eignung\s+nicht\s+sicher\s+belegt|ohne\s+technische[nr]?\s+eignung(?:snachweis)?|keine?\b.{0,80}\btechnische[nr]?\s+eignung)\b/iu' - - '/\b(?:preis|preise|kosten)\b.{0,140}\b(?:nicht\s+(?:explizit\s+)?(?:angegeben|enthalten|verfuegbar|verfügbar|bekannt|ausgewiesen)|nur\s+auf\s+anfrage|auf\s+anfrage|keine?\s+preis(?:angabe|information|e)?)\b/iu' - - '/\b(?:einzige[nrs]?\s+(?:direkte[nrs]?\s+)?(?:lösung|loesung|messgerät|messgeraet|option)|nur\s+ein\s+(?:direkte[nrs]?\s+)?(?:messgerät|messgeraet|produkt))\b/iu' - label: Nur Geräte anzeigen prompt: Suche im Shop nach {shop_query} und zeige daraus nur Geräte. action_type: role_filter target_role: main_device - hide_when_answer_matches_any: - - '/\bkeine?\s+(?:passende[nrs]?\s+)?(?:produktbezeichnung|shop-?treffer|treffer|produkte?)\b/iu' - - '/\bkeine?\b.{0,80}\b(?:explizit|direkt|passende[nrs]?|exakte[nrs]?)\b.{0,80}\b(?:produktbezeichnung|produktreihe|produktnummer|produkte?|shop-?treffer|treffer)\b/iu' - - '/\b(?:technische\s+eignung\s+nicht\s+sicher\s+belegt|ohne\s+technische[nr]?\s+eignung(?:snachweis)?|keine?\b.{0,80}\btechnische[nr]?\s+eignung)\b/iu' - - '/\b(?:preis|preise|kosten)\b.{0,140}\b(?:nicht\s+(?:explizit\s+)?(?:angegeben|enthalten|verfuegbar|verfügbar|bekannt|ausgewiesen)|nur\s+auf\s+anfrage|auf\s+anfrage|keine?\s+preis(?:angabe|information|e)?)\b/iu' - - '/\b(?:einzige[nrs]?\s+(?:direkte[nrs]?\s+)?(?:lösung|loesung|messgerät|messgeraet|option)|nur\s+ein\s+(?:direkte[nrs]?\s+)?(?:messgerät|messgeraet|produkt))\b/iu' knowledge: - label: Technische Details anzeigen prompt: Zeige nur zusätzliche technische Details zu {answer_anchor}. @@ -276,8 +262,7 @@ parameters: requires_answer_anchor: true hide_when_answer_detail_score_at_least: 2 hide_when_answer_matches_any: - - '/\b(?:Grenzwert(?:e)?|Messbereich(?:e)?|Messparameter|Indikator(?:typ)?|Einsatzgebiet(?:e)?|Technische Einordnung|Technische Eignung|Produktnummer|Verfügbarkeit)\b/iu' - - '/\b(?:preis|preise|kosten)\b.{0,140}\b(?:nicht\s+(?:explizit\s+)?(?:angegeben|enthalten|verfuegbar|verfügbar|bekannt|ausgewiesen)|nur\s+auf\s+anfrage|auf\s+anfrage|keine?\s+preis(?:angabe|information|e)?)\b/iu' + - '/\b(?:Grenzwert(?:e)?|Messbereich(?:e)?|Messparameter|Indikator(?:typ)?|Einsatzgebiet(?:e)?|Technische Einordnung|Technische Eignung|Produkt-?Nummer|Verfügbar|Verfuegbar|Preisinformation|Preisangabe)\b/iu' source_labels: external_url: Externe URL rag_knowledge: RAG Wissen diff --git a/config/retriex/genre.yaml b/config/retriex/genre.yaml index d10392f..ef9b3b5 100644 --- a/config/retriex/genre.yaml +++ b/config/retriex/genre.yaml @@ -1267,6 +1267,18 @@ parameters: - '/^\d+(?:[,.]\d+)?(?:m|mm|cm|ml|l)$/iu' - '/^[a-z]{1,4}\d{1,5}[a-z0-9-]*$/iu' - '/^\d{1,5}[a-z0-9-]*$/iu' + # Pure alpha model suffixes are only preserved when they appear next + # to numeric/model context in the same query, e.g. family number suffix. + # The explicit list is a domain-maintained safety anchor for known model + # suffixes; the pattern keeps the behavior extensible for new variants. + adjacent_variant_terms: + - thcl + - clf + - clt + - cl + - cal + adjacent_variant_patterns: + - '/^[a-z]{2,8}\d{0,4}$/iu' compound_prefix_match: origin: genre_native terms: diff --git a/patch_history/RETRIEX_PATCH_78_FOLLOWUP_PRICE_TARGET_ANCHOR_README.md b/patch_history/RETRIEX_PATCH_78_FOLLOWUP_PRICE_TARGET_ANCHOR_README.md new file mode 100644 index 0000000..b16b086 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_78_FOLLOWUP_PRICE_TARGET_ANCHOR_README.md @@ -0,0 +1,36 @@ +# RETRIEX PATCH 78 - Follow-up Price Target Anchor + +## Ziel + +`Preis anzeigen` darf bei einer bereits sichtbaren konkreten Produktempfehlung nicht wieder auf die ursprüngliche breite Shopquery zurückfallen. + +Beispiel: + +- Antwort empfiehlt sichtbar `Testomat 2000 THCL®` mit Produktnummer `100276`. +- Die Follow-up-Action darf nicht `Zeige mir die Preise zu gesamtchlor messgerät.` senden. +- Sie sendet nun fokussiert auf das sichtbare Produkt, z. B. `Zeige mir die Preise zu Testomat 2000 THCL® 100276.` + +## Hintergrund + +Die Action-Prompts nutzten bisher nur `{shop_query}`. Bei beratenden Fragen ist diese Query oft bewusst breit (`gesamtchlor messgerät`). Nach einer konkreten Antwort führt das bei Preis-Follow-ups zu generischen Shop-/RAG-Antworten und kann die zuvor korrekte Zielvariante wieder verlieren. + +## Umsetzung + +- `buildFollowUpActionContext()` erzeugt zusätzlich `shop_price_query`. +- `shop_price_query` wird bevorzugt aus sichtbaren Shop-Produkten in der Antwort gebildet. +- Bei genau einem sichtbaren Hauptgerät wird Produktname + Produktnummer verwendet. +- Bei genau einem sichtbaren Produkt allgemein wird ebenfalls Produktname + Produktnummer verwendet. +- Fallback bleibt der vorhandene Antwortanker bzw. die ursprüngliche Shopquery. +- `chat-messages.yaml` nutzt für die Preis-Action `{shop_price_query}` statt `{shop_query}`. + +## Nicht geändert + +- Kein Retrieval-/Ranking-Eingriff. +- Keine Shop-Matching-Änderung. +- Keine Testomat-/THCL-/Gesamtchlor-Sonderlogik im PHP-Core. +- Rollenfilter-Actions bleiben unverändert und profitieren nur indirekt von fokussierteren Preis-Follow-ups. + +## Lokale Checks + +- `php -l src/Agent/AgentRunner.php` +- YAML parse von `config/retriex/chat-messages.yaml` diff --git a/patch_history/RETRIEX_PATCH_79_MODEL_VARIANT_POSITIVE_FILTER_GUARD_README.md b/patch_history/RETRIEX_PATCH_79_MODEL_VARIANT_POSITIVE_FILTER_GUARD_README.md new file mode 100644 index 0000000..4a61457 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_79_MODEL_VARIANT_POSITIVE_FILTER_GUARD_README.md @@ -0,0 +1,55 @@ +# RetrieX Patch p79 - Model Variant Positive Filter Guard + +## Ziel + +Der positive Shopquery-Filter durfte numerische Modell-/Artikelcodes behalten, hat aber reine Modellvariantensuffixe wie `THCL` aus fokussierten Folgefragen entfernt. Dadurch wurde aus einem konkreten Preis-Follow-up wie `Testomat 2000 THCL 100276` die zu breite Shopquery `testomat 2000 100276`. + +p79 erhält solche Variantentokens generisch, wenn sie in unmittelbarem numerischem Modellkontext stehen. + +## Änderungen + +- `AgentRunner::filterShopQueryToPositiveTokens()` bewahrt jetzt konfigurierte benachbarte Variantentokens, wenn sie: + - entweder in der YAML-gepflegten Liste `adjacent_variant_terms` stehen oder gegen `adjacent_variant_patterns` matchen, + - direkt neben einem numerischen Modell-/Artikelkontext stehen, + - und in der Umgebung bereits mindestens zwei positive Kontexttokens erhalten bleiben. +- Neue YAML-Konfiguration: + - `shop_query_runtime.positive_token_filter.adjacent_variant_terms` + - `shop_query_runtime.positive_token_filter.adjacent_variant_patterns` + - Legacy-Fallbacks unter `agent.shop_runtime.query_cleanup.positive_token_filter.*` +- Die Water-Analysis-Genre-Konfiguration ergänzt bekannte Modell-/Variantensuffixe wie `thcl`, `clf`, `clt`, `cl`, `cal` als zentrale Liste. Die generische Pattern-Erkennung bleibt zusätzlich aktiv für neue Varianten. +- `Preis anzeigen` wird über YAML ausgeblendet, wenn die Antwort bereits eine fehlende/nicht verfügbare Preisinformation formuliert. +- `Technische Details anzeigen` wird über YAML defensiver ausgeblendet, wenn die Antwort bereits Produktnummer, Verfügbarkeit, Messparameter oder technische Eignung enthält. + +## Nicht geändert + +- Kein Eingriff in Retrieval, Ranking, Shop-Matching oder PromptBuilder. +- Keine Testomat-/THCL-/Gesamtchlor-Sonderlogik im PHP-Core. +- Keine neue harte Variantentokenliste im PHP-Core; bekannte Suffixe liegen in YAML. + +## Beispiel + +Vorher: + +```text +Zeige mir die Preise zu Testomat 2000 THCL® 100276. +=> testomat 2000 100276 +``` + +Nachher: + +```text +Zeige mir die Preise zu Testomat 2000 THCL® 100276. +=> testomat 2000 thcl 100276 +``` + +## Lokale Checks + +```bash +php -l src/Agent/AgentRunner.php +php -l src/Config/AgentRunnerConfig.php +php -l src/Config/ChatMessagesConfig.php +python3 YAML parse OK +PCRE patterns OK +variant preservation smoke OK +adjacent variant term list smoke OK +``` diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index fd3c3ae..1a0b3bf 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -1714,24 +1714,32 @@ final readonly class AgentRunner $allowedTokens = $this->buildPositiveShopQueryAllowedTokenSet(); $blockedTokens = $this->buildPositiveShopQueryBlockedTokenSet(); $codePatterns = $this->agentRunnerConfig->getShopQueryPositiveTokenFilterCodePatterns(); + $adjacentVariantPatterns = $this->agentRunnerConfig->getShopQueryPositiveTokenFilterAdjacentVariantPatterns(); + $adjacentVariantTokens = $this->buildShopQueryTokenSet( + $this->agentRunnerConfig->getShopQueryPositiveTokenFilterAdjacentVariantTerms() + ); - if ($allowedTokens === [] && $codePatterns === []) { + if ($allowedTokens === [] && $codePatterns === [] && $adjacentVariantPatterns === [] && $adjacentVariantTokens === []) { return $shopSearchQuery; } - $modelVariantSuffixTokens = $this->extractPositiveShopQueryModelVariantSuffixTokens($tokens, $blockedTokens, $codePatterns); - $kept = []; foreach ($tokens as $token) { if (isset($blockedTokens[$token]) || isset($kept[$token])) { continue; } - if ( - isset($allowedTokens[$token]) - || isset($modelVariantSuffixTokens[$token]) - || $this->matchesAnyConfiguredShopQueryCodePattern($token, $codePatterns) - ) { + if (isset($allowedTokens[$token]) || $this->matchesAnyConfiguredShopQueryCodePattern($token, $codePatterns)) { + $kept[$token] = $token; + } + } + + foreach ($tokens as $index => $token) { + if (isset($blockedTokens[$token]) || isset($kept[$token])) { + continue; + } + + if ($this->shouldKeepAdjacentVariantShopQueryToken($token, $index, $tokens, $kept, $adjacentVariantPatterns, $adjacentVariantTokens)) { $kept[$token] = $token; } } @@ -1740,11 +1748,62 @@ final readonly class AgentRunner return $shopSearchQuery; } - $filtered = implode(' ', array_values($kept)); + $filteredTokens = []; + foreach ($tokens as $token) { + if (isset($kept[$token])) { + $filteredTokens[] = $token; + } + } + + $filtered = implode(' ', $filteredTokens); return $filtered !== '' ? $filtered : $shopSearchQuery; } + /** + * @param string[] $tokens + * @param array $kept + * @param string[] $variantPatterns + * @param array $variantTokens + */ + private function shouldKeepAdjacentVariantShopQueryToken( + string $token, + int $index, + array $tokens, + array $kept, + array $variantPatterns, + array $variantTokens + ): bool { + if (!isset($variantTokens[$token]) && !$this->matchesAnyConfiguredShopQueryCodePattern($token, $variantPatterns)) { + return false; + } + + $hasAdjacentNumericContext = false; + $nearbyKeptContextCount = 0; + + for ($offset = -2; $offset <= 2; ++$offset) { + if ($offset === 0) { + continue; + } + + $nearbyIndex = $index + $offset; + if (!isset($tokens[$nearbyIndex])) { + continue; + } + + $nearbyToken = $tokens[$nearbyIndex]; + if (isset($kept[$nearbyToken])) { + ++$nearbyKeptContextCount; + } + + if (abs($offset) === 1 && preg_match('/\d/u', $nearbyToken) === 1) { + $hasAdjacentNumericContext = true; + } + } + + return $hasAdjacentNumericContext && $nearbyKeptContextCount >= 2; + } + /** * @return array */ @@ -1813,53 +1872,6 @@ final readonly class AgentRunner return false; } - /** - * Preserve model variant suffixes that are attached to an already retained - * model number in the same query, for example family-number-code product - * names. This prevents the positive-token filter from degrading a specific - * model variant to its generic base model. - * - * @param string[] $tokens - * @param array $blockedTokens - * @param string[] $codePatterns - * @return array - */ - private function extractPositiveShopQueryModelVariantSuffixTokens( - array $tokens, - array $blockedTokens, - array $codePatterns - ): array { - $suffixTokens = []; - $count = count($tokens); - - for ($index = 0; $index < $count; $index++) { - $token = $tokens[$index] ?? ''; - if (!$this->matchesAnyConfiguredShopQueryCodePattern($token, $codePatterns)) { - continue; - } - - for ($suffixIndex = $index + 1; $suffixIndex < $count; $suffixIndex++) { - $suffix = $tokens[$suffixIndex] ?? ''; - - if (isset($blockedTokens[$suffix]) || !$this->isPositiveShopQueryModelVariantSuffixToken($suffix)) { - break; - } - - $suffixTokens[$suffix] = true; - } - } - - return $suffixTokens; - } - - private function isPositiveShopQueryModelVariantSuffixToken(string $token): bool - { - $token = trim($token); - - return $token !== '' - && preg_match('/^[\p{L}]{2,8}\d{0,3}$/u', $token) === 1; - } - private function cleanupDirectProductAttributeShopQuery(string $prompt, string $shopSearchQuery): string { $shopSearchQuery = trim($shopSearchQuery); @@ -5290,20 +5302,24 @@ final readonly class AgentRunner /** * @param ShopProductResult[] $shopResults - * @return array{shop_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array} + * @return array{shop_query:string, shop_price_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array} */ private function buildFollowUpActionContext(array $shopResults, string $shopSearchQuery, string $answerText): array { $plainAnswerText = $this->normalizeOneLine($this->plainTextFromHtml($answerText)); $roleCounts = $this->buildFollowUpActionRoleCounts($shopResults); - $displayedRoleCounts = $this->buildFollowUpActionDisplayedRoleCounts($shopResults, $plainAnswerText); + $displayedProducts = $this->buildFollowUpActionDisplayedProducts($shopResults, $plainAnswerText); + $displayedRoleCounts = $this->buildFollowUpActionProductRoleCounts($displayedProducts); if (array_sum($displayedRoleCounts) > 0) { $roleCounts = $displayedRoleCounts; } + $normalizedShopQuery = $this->normalizeOneLine($shopSearchQuery); + return [ - 'shop_query' => $this->normalizeOneLine($shopSearchQuery), + 'shop_query' => $normalizedShopQuery, + 'shop_price_query' => $this->buildFollowUpActionPriceQuery($displayedProducts, $plainAnswerText, $normalizedShopQuery), 'answer_has_price' => $this->followUpActionAnswerAlreadyContainsPrice($plainAnswerText), 'answer_text' => $plainAnswerText, 'answer_anchor' => $this->buildFollowUpActionAnswerAnchor($plainAnswerText), @@ -5346,15 +5362,17 @@ final readonly class AgentRunner /** * @param ShopProductResult[] $shopResults - * @return array + * @return ShopProductResult[] */ - private function buildFollowUpActionDisplayedRoleCounts(array $shopResults, string $answerText): array + private function buildFollowUpActionDisplayedProducts(array $shopResults, string $answerText): array { - $roleCounts = $this->emptyFollowUpActionRoleCounts(); if ($answerText === '') { - return $roleCounts; + return []; } + $products = []; + $seen = []; + foreach ($shopResults as $product) { if (!$product instanceof ShopProductResult) { continue; @@ -5364,6 +5382,31 @@ final readonly class AgentRunner continue; } + $key = mb_strtolower($this->normalizeOneLine(($product->productNumber ?? '') . ' ' . $product->name), 'UTF-8'); + if ($key === '' || isset($seen[$key])) { + continue; + } + + $seen[$key] = true; + $products[] = $product; + } + + return $products; + } + + /** + * @param ShopProductResult[] $products + * @return array + */ + private function buildFollowUpActionProductRoleCounts(array $products): array + { + $roleCounts = $this->emptyFollowUpActionRoleCounts(); + + foreach ($products as $product) { + if (!$product instanceof ShopProductResult) { + continue; + } + $this->countFollowUpActionProductRole($roleCounts, $product); } @@ -5409,6 +5452,93 @@ final readonly class AgentRunner return str_contains($normalizedAnswer, $productName); } + /** + * @param ShopProductResult[] $displayedProducts + */ + private function buildFollowUpActionPriceQuery(array $displayedProducts, string $answerText, string $fallbackShopQuery): string + { + $numberedProducts = $this->filterFollowUpActionDisplayedProductsWithNumber($displayedProducts, $answerText); + if (count($numberedProducts) === 1) { + return $this->buildFollowUpActionProductQuery($numberedProducts[0]); + } + + $focusedProducts = $this->filterFollowUpActionPrimaryDisplayedProducts($displayedProducts); + if (count($focusedProducts) === 1) { + return $this->buildFollowUpActionProductQuery($focusedProducts[0]); + } + + if (count($displayedProducts) === 1) { + return $this->buildFollowUpActionProductQuery($displayedProducts[0]); + } + + $answerAnchor = $this->buildFollowUpActionAnswerAnchor($answerText); + if ($answerAnchor !== '') { + return $answerAnchor; + } + + return $fallbackShopQuery; + } + + /** + * @param ShopProductResult[] $products + * @return ShopProductResult[] + */ + private function filterFollowUpActionDisplayedProductsWithNumber(array $products, string $answerText): array + { + $numberedProducts = []; + $normalizedAnswer = mb_strtolower($this->normalizeOneLine($answerText), 'UTF-8'); + + foreach ($products as $product) { + if (!$product instanceof ShopProductResult) { + continue; + } + + $productNumber = $this->normalizeOneLine((string) $product->productNumber); + if ($productNumber === '' || mb_strlen($productNumber, 'UTF-8') < 3) { + continue; + } + + if (str_contains($normalizedAnswer, mb_strtolower($productNumber, 'UTF-8'))) { + $numberedProducts[] = $product; + } + } + + return $numberedProducts; + } + + /** + * @param ShopProductResult[] $products + * @return ShopProductResult[] + */ + private function filterFollowUpActionPrimaryDisplayedProducts(array $products): array + { + $mainDevices = []; + + foreach ($products as $product) { + if (!$product instanceof ShopProductResult) { + continue; + } + + if ($this->resolveFollowUpActionShopProductRole($product) === ProductRoleResolver::ROLE_MAIN_DEVICE) { + $mainDevices[] = $product; + } + } + + return $mainDevices; + } + + private function buildFollowUpActionProductQuery(ShopProductResult $product): string + { + $parts = [$product->name]; + + $productNumber = $this->normalizeOneLine((string) $product->productNumber); + if ($productNumber !== '') { + $parts[] = $productNumber; + } + + return $this->normalizeOneLine(implode(' ', array_filter($parts, static fn(string $part): bool => trim($part) !== ''))); + } + private function buildFollowUpActionAnswerAnchor(string $answerText): string { $anchors = []; @@ -5490,14 +5620,14 @@ final readonly class AgentRunner private function followUpActionAnswerAlreadyContainsPrice(string $answerText): bool { - return preg_match('/(?:\bpreise?\b.{0,80}\d+[,.]\d{2}\s*(?:€|eur\b)|\d+[,.]\d{2}\s*(?:€|eur\b)|(?:€|eur\b)\s*\d+[,.]\d{2})/iu', $answerText) === 1; + return preg_match('/(?:\bpreis\b.{0,24}\d+[,.]\d{2}|\d+[,.]\d{2}\s*(?:€|eur)\b|(?:€|eur)\s*\d+[,.]\d{2})/iu', $answerText) === 1; } /** * @param array> $actions * @param array $seenActionKeys * @param array> $items - * @param array{shop_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array} $context + * @param array{shop_query:string, shop_price_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array} $context */ private function appendFollowUpActions(array &$actions, array &$seenActionKeys, array $items, array $context): void { @@ -5533,7 +5663,7 @@ final readonly class AgentRunner /** * @param array $item - * @param array{shop_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array} $context + * @param array{shop_query:string, shop_price_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array} $context */ private function shouldShowFollowUpAction(array $item, array $context): bool { @@ -5558,7 +5688,7 @@ final readonly class AgentRunner } if ($actionType === 'price_details') { - return $context['shop_query'] !== '' && !$context['answer_has_price']; + return $context['shop_price_query'] !== '' && !$context['answer_has_price']; } if ($actionType === 'role_filter') { @@ -5658,12 +5788,13 @@ final readonly class AgentRunner } /** - * @param array{shop_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array} $context + * @param array{shop_query:string, shop_price_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array} $context */ private function renderFollowUpActionPrompt(string $prompt, array $context): string { $rendered = strtr($prompt, [ '{shop_query}' => $context['shop_query'], + '{shop_price_query}' => $context['shop_price_query'], '{answer_anchor}' => $context['answer_anchor'], ]); diff --git a/src/Config/AgentRunnerConfig.php b/src/Config/AgentRunnerConfig.php index e0a55e1..c0d1dc4 100644 --- a/src/Config/AgentRunnerConfig.php +++ b/src/Config/AgentRunnerConfig.php @@ -1364,6 +1364,24 @@ final class AgentRunnerConfig ?: $this->getOptionalStringList('shop_runtime.query_cleanup.positive_token_filter.code_patterns'); } + /** + * @return string[] + */ + public function getShopQueryPositiveTokenFilterAdjacentVariantPatterns(): array + { + return $this->genreStringList('shop_query_runtime.positive_token_filter.adjacent_variant_patterns') + ?: $this->getOptionalStringList('shop_runtime.query_cleanup.positive_token_filter.adjacent_variant_patterns'); + } + + /** + * @return string[] + */ + public function getShopQueryPositiveTokenFilterAdjacentVariantTerms(): array + { + return $this->genreStringList('shop_query_runtime.positive_token_filter.adjacent_variant_terms') + ?: $this->getOptionalStringList('shop_runtime.query_cleanup.positive_token_filter.adjacent_variant_terms'); + } + /** * @return string[] */ diff --git a/src/Config/RetriexEffectiveConfigProvider.php b/src/Config/RetriexEffectiveConfigProvider.php index 1a1217f..103d5a7 100644 --- a/src/Config/RetriexEffectiveConfigProvider.php +++ b/src/Config/RetriexEffectiveConfigProvider.php @@ -1366,7 +1366,9 @@ final readonly class RetriexEffectiveConfigProvider $this->validateStringList($this->toList($positiveTokenFilter['allowed_terms'] ?? []), 'genre.configuration_values.shop_query_runtime.positive_token_filter.allowed_terms', $errors, $warnings); $this->validateStringList($this->toList($positiveTokenFilter['blocked_terms'] ?? []), 'genre.configuration_values.shop_query_runtime.positive_token_filter.blocked_terms', $errors, $warnings); + $this->validateStringList($this->toList($positiveTokenFilter['adjacent_variant_terms'] ?? []), 'genre.configuration_values.shop_query_runtime.positive_token_filter.adjacent_variant_terms', $errors, $warnings); $this->validateRegexPatternList($positiveTokenFilter['code_patterns'] ?? [], 'genre.configuration_values.shop_query_runtime.positive_token_filter.code_patterns', $errors); + $this->validateRegexPatternList($positiveTokenFilter['adjacent_variant_patterns'] ?? [], 'genre.configuration_values.shop_query_runtime.positive_token_filter.adjacent_variant_patterns', $errors); } foreach ($this->collectGenreConfigurationValueSourcePaths($configurationValues) as $valuePath => $sourcePaths) {