From 96375668b26e64fa53d10eb36043e225ad8f85c8 Mon Sep 17 00:00:00 2001 From: team 1 Date: Sun, 10 May 2026 08:55:05 +0200 Subject: [PATCH] p75+76 --- config/retriex/chat-messages.yaml | 15 +- config/retriex/commerce.yaml | 2 +- config/retriex/genre.yaml | 6 +- ...H_75_FOLLOWUP_PRICE_ACTION_GUARD_README.md | 46 +++++++ ...TCH_76_MODEL_VARIANT_SHOP_REPAIR_README.md | 79 +++++++++++ ...7_PRICE_UNAVAILABLE_ACTION_GUARD_README.md | 44 ++++++ src/Agent/AgentRunner.php | 79 +++++++---- src/Commerce/CommerceQueryParser.php | 7 +- src/Commerce/SearchRepairService.php | 130 ++++++++++++++++++ 9 files changed, 379 insertions(+), 29 deletions(-) create mode 100644 patch_history/RETRIEX_PATCH_75_FOLLOWUP_PRICE_ACTION_GUARD_README.md create mode 100644 patch_history/RETRIEX_PATCH_76_MODEL_VARIANT_SHOP_REPAIR_README.md create mode 100644 patch_history/RETRIEX_PATCH_77_PRICE_UNAVAILABLE_ACTION_GUARD_README.md diff --git a/config/retriex/chat-messages.yaml b/config/retriex/chat-messages.yaml index 6ac55cb..be0b536 100644 --- a/config/retriex/chat-messages.yaml +++ b/config/retriex/chat-messages.yaml @@ -245,18 +245,30 @@ parameters: 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' - 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}. @@ -264,7 +276,8 @@ parameters: requires_answer_anchor: true hide_when_answer_detail_score_at_least: 2 hide_when_answer_matches_any: - - '/\b(?:Grenzwert(?:e)?|Messbereich(?:e)?|Indikator(?:typ)?|Einsatzgebiet(?:e)?|Technische Einordnung)\b/iu' + - '/\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' source_labels: external_url: Externe URL rag_knowledge: RAG Wissen diff --git a/config/retriex/commerce.yaml b/config/retriex/commerce.yaml index ea62d9b..e76357a 100644 --- a/config/retriex/commerce.yaml +++ b/config/retriex/commerce.yaml @@ -8,7 +8,7 @@ parameters: retriex.commerce.sales_channel_access_key: '%env(SHOPWARE_SALES_CHANNEL_ACCESS_KEY)%' retriex.commerce.search_repair.enabled: true - retriex.commerce.search_repair.max_queries: 1 + retriex.commerce.search_repair.max_queries: 2 retriex.commerce.search_repair.min_primary_results_without_repair: 2 # Commerce query parser configuration. diff --git a/config/retriex/genre.yaml b/config/retriex/genre.yaml index b88d2f8..d10392f 100644 --- a/config/retriex/genre.yaml +++ b/config/retriex/genre.yaml @@ -1437,6 +1437,8 @@ parameters: - '- 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 an accessory, indicator, reagent, kit, set, or consumable code is explicitly requested, do not merge shop variants whose code has an additional suffix, prefix, or variant token unless the user explicitly requested that full variant 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.' + - '- If retrieved knowledge identifies a concrete device/model variant with a suffix or code and live shop data contains the same concrete identity, answer with that specific variant instead of downgrading to the generic base family.' + - '- If the primary shop hit is only a generic base family but extended shop search provides more specific RAG-identified variants, use the specific variants for the product recommendation and keep any generic base-family hit separate.' prompt_keyword_views: origin: genre_native technical_product_keywords: @@ -1593,11 +1595,11 @@ parameters: specific_model_candidate_patterns: - /\b([A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*(?:\s+[A-Za-zÄÖÜäöüß0-9][A-Za-zÄÖÜäöüß0-9®\-]*){0,3}\s+\d{2,5}(?:\s+[A-ZÄÖÜ]{1,8})?)\b/u patterns: - model_candidate: /\b([A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*(?:\s+[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*){0,2}\s+\d{2,5}[A-Za-z0-9\-]*)\b/u + model_candidate: /\b([A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*(?:\s+[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*){0,2}\s+\d{2,5}[A-Za-z0-9\-]*(?:\s+[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß0-9\-]{1,7})?)\b/u accessory_candidate_template: /\b((?:{terms})\s+\d{1,5}[A-Za-z0-9\-]*)\b/iu requested_accessory_code: /\b(?:indikator(?:typ)?|indicator(?:\s*type)?|reagenz|reagent)\s*([A-Za-z]{0,3}\s*\d{1,5}[A-Za-z0-9\-]*)\b/iu accessory_or_bundle_template: /\b({terms})\b/iu - model_like: /\b[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*(?:\s+[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*){0,2}\s+\d{2,5}[A-Za-z0-9\-]*\b/u + model_like: /\b[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*(?:\s+[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*){0,2}\s+\d{2,5}[A-Za-z0-9\-]*(?:\s+[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß0-9\-]{1,7})?\b/u specificity_boost_template: /\b(?:{terms})\b/iu contains_digit: /\d/u whitespace_collapse: /\s+/u diff --git a/patch_history/RETRIEX_PATCH_75_FOLLOWUP_PRICE_ACTION_GUARD_README.md b/patch_history/RETRIEX_PATCH_75_FOLLOWUP_PRICE_ACTION_GUARD_README.md new file mode 100644 index 0000000..6c1ca9a --- /dev/null +++ b/patch_history/RETRIEX_PATCH_75_FOLLOWUP_PRICE_ACTION_GUARD_README.md @@ -0,0 +1,46 @@ +# RetrieX Patch p75 - Follow-up Price Action Guard + +## Ziel + +Nach p74 konnte bei Antworten mit Preisangaben weiterhin die Folgeaktion `Preis anzeigen` erscheinen. Beispiel: + +```text +Zeige mir die Preise zu testomat resthärte indikator. +``` + +Die Antwort enthielt bereits konkrete Preise wie `98,20 €`, trotzdem wurde erneut `Preis anzeigen` angeboten. Zusätzlich sollte die Preis-Folgeaktion bei klaren No-Match-/Eignungswarnungen defensiver sein. + +## Änderung + +- Die Preiserkennung für Follow-up-Actions erkennt nun auch Preisformate wie `98,20 €` zuverlässig. +- Die bisherige Regex hatte bei `€` ein Wortgrenzenproblem und erkannte außerdem `Preise` nicht sicher. +- Shop-Folgeaktionen werden zusätzlich per YAML ausgeblendet, wenn die Antwort klar keinen direkten/expliziten Produkttreffer oder keine gesicherte technische Eignung formuliert. Dadurch entstehen bei No-Value-Antworten keine künstlichen Filter- oder Preis-Actions. + +## Warum generisch? + +Der Patch enthält keine Testomat-, Resthärte-, Indikator- oder TH-Code-Sonderlogik. Er verbessert nur die generische Action-Sichtbarkeit anhand von Preisformaten und konfigurierbaren No-Value-/No-Match-Formulierungen. + +## Erwartete Wirkung + +- Wenn eine Antwort bereits Preise wie `83,30 €`, `98,20 €` oder `Preis: 109,20 €` enthält, erscheint `Preis anzeigen` nicht erneut. +- Bei klaren No-Match-/Eignungswarnungen wird keine Preisaktion künstlich angeboten. +- Rollenfilter-Actions bleiben unverändert kontextsensitiv, werden aber bei klaren No-Value-/No-Match-Antworten ebenfalls ausgeblendet. + +## Checks + +Lokal geprüft: + +```bash +php -l src/Agent/AgentRunner.php +python3 YAML parse config/retriex/chat-messages.yaml +php smoke: Preisformate mit Eurozeichen werden erkannt +python3 smoke: zusätzliche YAML-No-Value-Patterns matchen +``` + +In der Zielumgebung zusätzlich ausführen: + +```bash +bin/console mto:agent:config:validate +bin/console mto:agent:regression:test +bin/console mto:agent:config:audit-source --details +``` diff --git a/patch_history/RETRIEX_PATCH_76_MODEL_VARIANT_SHOP_REPAIR_README.md b/patch_history/RETRIEX_PATCH_76_MODEL_VARIANT_SHOP_REPAIR_README.md new file mode 100644 index 0000000..e6f8e48 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_76_MODEL_VARIANT_SHOP_REPAIR_README.md @@ -0,0 +1,79 @@ +# RETRIEX PATCH 76 - Model Variant Shop Repair + +## Ziel + +Produktnahe Messgeräte-Anfragen mit technisch belegten Modellvarianten dürfen nicht auf eine generische Basisfamilie zurückfallen, wenn RAG-Wissen konkrete Varianten nennt und die Shop-Suche nur eine zu breite Query verwendet. + +Beispielklasse: + +- User: `ich möchte gern gesamtchlor messen. welche messgerät sollte ich nutzen` +- RAG nennt konkrete Varianten wie `Testomat 2000 CLF` / `Testomat 2000 CLT` +- Shopquery darf daraus nicht nur `Testomat 2000` machen und die Variante verlieren + +## Änderungen + +### 1. Modellvariantensuffixe in finalen Shopqueries bewahren + +`AgentRunner::filterShopQueryToPositiveTokens()` bewahrt nun generisch alphabetische Modell-/Variantensuffixe, wenn sie direkt an ein bereits erlaubtes Modell-/Code-Token anschließen. + +Beispiel: + +- vorher: `Testomat 2000 CLF` -> `testomat 2000` +- nachher: `Testomat 2000 CLF` -> `testomat 2000 clf` + +Das ist generisch und nicht auf CLF/CLT oder Testomat hardcodiert. + +### 2. Mehrere Modellvariantensuffixe in CommerceQueryParser erhalten + +`CommerceQueryParser::compactShopSearchTokens()` erhält jetzt nicht nur ein einzelnes Suffix nach einer Modellnummer, sondern eine zusammenhängende Suffixkette. + +Beispielklasse: + +- `Family 2000 ABC DEF` behält beide Varianten-/Suffix-Tokens, sofern sie dem generischen Suffixmuster entsprechen. + +### 3. RAG-gefundene konkrete Modellvarianten als Repair-Queries nutzen + +`SearchRepairService` baut bei schwachen primären Shop-Treffern generische Repair-Queries aus konkreten Modellvarianten, die im RAG-Kontext gefunden wurden. + +Dabei werden bevorzugt spezifische Varianten mit Suffix/Code genutzt, statt nur die Basisfamilie zu suchen. + +### 4. Modellkandidaten-Pattern erweitert + +`genre.yaml` erkennt Modellkandidaten mit getrenntem Variantensuffix, z. B. generisch: + +- `Produkt 2000 ABC` +- `Produkt 2000 ABC DEF` + +### 5. Repair-Budget leicht erhöht + +`retriex.commerce.search_repair.max_queries` wurde von `1` auf `2` erhöht, damit bei zwei plausiblen Varianten nicht nur die erste Variante abgefragt wird. + +## Nicht geändert + +- Keine Testomat-Sonderlogik +- Keine CLF-/CLT-Sonderlogik im PHP-Core +- Kein neues Ranking-/Retrieval-Scoring +- Keine Änderung an Shopware Criteria-Struktur +- Keine Änderung an Produktrollenlogik + +## Lokale Checks + +- `php -l src/Commerce/SearchRepairService.php` +- `php -l src/Agent/AgentRunner.php` +- `php -l src/Commerce/CommerceQueryParser.php` +- `php -l src/Config/AgentRunnerConfig.php` +- `php -l src/Config/SearchRepairConfig.php` +- YAML Parse für `genre.yaml`, `commerce.yaml`, `agent.yaml`, `search_repair.yaml` +- Regex-Smoke: Modellkandidaten-Pattern erkennt `Testomat 2000 CLF` und `Testomat 2000 CLT` + +## Empfohlener Regressionstest + +```text +ich möchte gern gesamtchlor messen. welche messgerät sollte ich nutzen +``` + +Erwartung: + +- Shop-/Repair-Logik verliert Suffixe wie `CLF`/`CLT` nicht mehr. +- Wenn Shopdaten konkrete Varianten liefern, sollen diese Varianten in der Antwort erscheinen. +- Eine generische Basisfamilie wie `Testomat 2000` darf nicht die konkrete Variante ersetzen. diff --git a/patch_history/RETRIEX_PATCH_77_PRICE_UNAVAILABLE_ACTION_GUARD_README.md b/patch_history/RETRIEX_PATCH_77_PRICE_UNAVAILABLE_ACTION_GUARD_README.md new file mode 100644 index 0000000..8e2ff0d --- /dev/null +++ b/patch_history/RETRIEX_PATCH_77_PRICE_UNAVAILABLE_ACTION_GUARD_README.md @@ -0,0 +1,44 @@ +# RetrieX Patch p77 - Price Unavailable Action Guard + +## Ziel + +Nach p76 ist die fachliche Auswahl fuer Gesamtchlor deutlich besser: Bei `ich moechte gern gesamtchlor messen...` wird der konkrete Shop-/RAG-Anker `Testomat 2000 THCL` gehalten. Danach konnte die Follow-up-UI aber nach einer Preisantwort erneut Actions wie `Preis anzeigen`, `Nur Zubehoer anzeigen`, `Nur Geraete anzeigen` oder `Technische Details anzeigen` anbieten, obwohl die Antwort bereits sagte, dass der konkrete Preis nicht explizit angegeben bzw. nur auf Anfrage verfuegbar ist. + +## Änderung + +- Die Preis-Erkennung fuer Follow-up-Actions erkennt Preisformate wie `7.202,00 €` und `98,20 €` robuster. +- `Preis anzeigen` wird ausgeblendet, wenn die Antwort bereits Preise enthaelt oder klar sagt, dass der Zielpreis nicht angegeben, nicht enthalten, nicht ausgewiesen oder nur auf Anfrage verfuegbar ist. +- Rollenfilter-Actions werden bei No-Match-, No-Eignung- und Preis-nicht-verfuegbar-Antworten ausgeblendet. +- Rollenfilter-Actions werden ebenfalls ausgeblendet, wenn die Antwort klar eine einzige direkte Loesung bzw. ein einziges direktes Messgeraet nennt; dann erzeugen Filter keinen Mehrwert. +- `Technische Details anzeigen` wird ausgeblendet, wenn die Antwort bereits technische Eignung, Messparameter, Produktnummer, Verfuegbarkeit oder eine Preis-nicht-verfuegbar-Einordnung liefert. + +## Warum generisch? + +Der Patch enthaelt keine Testomat-, THCL-, Gesamtchlor- oder Modell-Sonderlogik. Er erweitert nur die konfigurierbaren Follow-up-Action-Guards und die generische Preisformat-Erkennung. + +## Erwartete Wirkung + +- Nach einer Preisantwort wie `Der Preis fuer dieses Geraet ist nicht explizit angegeben` erscheint keine erneute `Preis anzeigen`-Action. +- Bei einer klaren Einzelgeraet-Antwort entstehen keine sinnlosen Rollenfilter wie `Nur Zubehoer anzeigen` oder `Nur Geraete anzeigen`. +- Bei Antworten mit technischen Kerndetails erscheint `Technische Details anzeigen` nicht mehr als redundante Aktion. + +## Checks + +Lokal geprueft: + +```bash +php -l src/Agent/AgentRunner.php +php -l src/Config/AgentRunnerConfig.php +php -l src/Config/ChatMessagesConfig.php +python3 YAML parse config/retriex/chat-messages.yaml +php PCRE pattern validation for follow-up action hide patterns +php smoke: price-unavailable pattern matches German no-price wording +``` + +In der Zielumgebung zusaetzlich ausfuehren: + +```bash +bin/console mto:agent:config:validate +bin/console mto:agent:regression:test +bin/console mto:agent:config:audit-source --details +``` diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index ad18a17..fd3c3ae 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -1719,13 +1719,19 @@ final readonly class AgentRunner 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]) || $this->matchesAnyConfiguredShopQueryCodePattern($token, $codePatterns)) { + if ( + isset($allowedTokens[$token]) + || isset($modelVariantSuffixTokens[$token]) + || $this->matchesAnyConfiguredShopQueryCodePattern($token, $codePatterns) + ) { $kept[$token] = $token; } } @@ -1807,6 +1813,53 @@ 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); @@ -3321,10 +3374,6 @@ final readonly class AgentRunner return $shopResults; } - if ($this->isMixedDeviceAndAccessoryProductRequest($prompt, $shopSearchQuery)) { - return $shopResults; - } - $primaryMatches = []; $corpusMatches = []; @@ -3383,10 +3432,6 @@ final readonly class AgentRunner return $emptyResult; } - if ($this->isMixedDeviceAndAccessoryProductRequest($prompt, $shopSearchQuery)) { - return $emptyResult; - } - $repairQuery = $this->buildDirectProductPrimaryIdentityRepairQuery( shopSearchQuery: $shopSearchQuery, requestedTerms: $requestedTerms @@ -4070,7 +4115,7 @@ final readonly class AgentRunner } $terms = []; - foreach ($this->agentRunnerConfig->getDirectShopResultProductIdentityTerms() as $term) { + foreach ($this->agentRunnerConfig->getShopQueryProductAttributeCleanupProductTypeTerms() as $term) { if ($this->containsAllShopQueryTokens($combined, $term)) { $terms[] = $term; } @@ -4079,17 +4124,6 @@ final readonly class AgentRunner return array_values(array_unique($terms)); } - private function isMixedDeviceAndAccessoryProductRequest(string $prompt, string $shopSearchQuery): bool - { - $combined = mb_strtolower($this->normalizeOneLine($prompt . ' ' . $shopSearchQuery), 'UTF-8'); - if ($combined === '') { - return false; - } - - return $this->containsAnyConfiguredTerm($combined, $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords()) - && $this->containsAnyConfiguredTerm($combined, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords()); - } - private function containsAllShopQueryTokens(string $text, string $term): bool { $tokens = array_fill_keys($this->tokenizeShopQueryCandidate($text), true); @@ -4235,7 +4269,6 @@ final readonly class AgentRunner || !$shopSearchAttempted || $shopSearchHadSystemFailure || $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery) === [] - || $this->isMixedDeviceAndAccessoryProductRequest($prompt, $shopSearchQuery) ) { return ''; } @@ -5457,7 +5490,7 @@ final readonly class AgentRunner private function followUpActionAnswerAlreadyContainsPrice(string $answerText): bool { - return preg_match('/(?:\bpreis\b.{0,24}\d+[,.]\d{2}|\d+[,.]\d{2}\s*(?:€|eur)\b|(?:€|eur)\s*\d+[,.]\d{2})/iu', $answerText) === 1; + 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; } /** diff --git a/src/Commerce/CommerceQueryParser.php b/src/Commerce/CommerceQueryParser.php index 8d28553..4c0f64a 100644 --- a/src/Commerce/CommerceQueryParser.php +++ b/src/Commerce/CommerceQueryParser.php @@ -308,8 +308,11 @@ final readonly class CommerceQueryParser $keep[$previousIndex] = true; } - $nextIndex = $index + 1; - if (isset($tokens[$nextIndex]) && $this->isModelSuffixToken($tokens[$nextIndex])) { + for ($nextIndex = $index + 1; isset($tokens[$nextIndex]); $nextIndex++) { + if (!$this->isModelSuffixToken($tokens[$nextIndex])) { + break; + } + $keep[$nextIndex] = true; } } diff --git a/src/Commerce/SearchRepairService.php b/src/Commerce/SearchRepairService.php index 2d785ff..c8812d0 100644 --- a/src/Commerce/SearchRepairService.php +++ b/src/Commerce/SearchRepairService.php @@ -230,6 +230,21 @@ final readonly class SearchRepairService } } + if ( + $requestedAccessoryCodes === [] + && $accessoryCandidates === [] + ) { + $modelVariantQueries = $this->buildSpecificModelVariantRepairQueries( + prompt: $prompt, + primaryQuery: $primaryQuery, + modelCandidates: $modelCandidates + ); + + if ($modelVariantQueries !== []) { + return $this->normalizeRepairQueries($modelVariantQueries, $primaryQuery); + } + } + $topPrimaryName = $primaryShopResults[0]->name ?? ''; $topPrimaryProductNumber = $primaryShopResults[0]->productNumber ?? null; $topPrimaryPhrase = trim($topPrimaryName . ' ' . ($topPrimaryProductNumber ?? '')); @@ -339,6 +354,121 @@ final readonly class SearchRepairService return $query !== '' ? [$query] : []; } + /** + * Build repair searches for specific model variants discovered in RAG evidence. + * This keeps suffix variants such as family-number-code product names intact + * instead of falling back to the generic base model. + * + * @param string[] $modelCandidates + * @return string[] + */ + private function buildSpecificModelVariantRepairQueries( + string $prompt, + string $primaryQuery, + array $modelCandidates + ): array { + if ($modelCandidates === []) { + return []; + } + + $combinedQueryText = trim($prompt . ' ' . $primaryQuery); + $decorated = []; + + foreach ($modelCandidates as $index => $candidate) { + $candidate = $this->sanitizeQuery($candidate); + if ($candidate === '' || !$this->isSpecificModelVariantCandidate($candidate)) { + continue; + } + + if ($this->queryAlreadyContainsCandidate($combinedQueryText, $candidate)) { + continue; + } + + $decorated[] = [ + 'candidate' => $candidate, + 'score' => $this->scoreSpecificModelVariantCandidate($candidate, $combinedQueryText), + 'index' => $index, + ]; + } + + if ($decorated === []) { + return []; + } + + usort($decorated, static function (array $a, array $b): int { + if ($a['score'] === $b['score']) { + return $a['index'] <=> $b['index']; + } + + return $b['score'] <=> $a['score']; + }); + + return array_values(array_unique(array_map( + static fn(array $row): string => $row['candidate'], + $decorated + ))); + } + + private function isSpecificModelVariantCandidate(string $candidate): bool + { + return preg_match('/\b\d{2,5}[A-Za-z0-9\-]*\s+[A-Za-zÄÖÜäöüß]{2,8}\d{0,3}(?:\s+[A-Za-zÄÖÜäöüß]{2,8})?\b/u', $candidate) === 1 + || preg_match('/\b\d{2,5}[A-Za-z]{1,8}\d{0,3}\b/u', $candidate) === 1; + } + + private function scoreSpecificModelVariantCandidate(string $candidate, string $queryText): int + { + $score = $this->scoreCandidate($candidate); + $suffix = $this->extractModelVariantSuffix($candidate); + + if ($suffix !== '') { + $suffixLength = mb_strlen(preg_replace('/\s+/u', '', $suffix) ?? $suffix, 'UTF-8'); + $score += min(4, $suffixLength); + + $normalizedQuery = $this->normalizeForRepairMatching($queryText); + $normalizedSuffix = $this->normalizeForRepairMatching($suffix); + if ($normalizedSuffix !== '' && preg_match('/\b' . preg_quote($normalizedSuffix, '/') . '\b/u', $normalizedQuery) === 1) { + $score += 12; + } + + if (preg_match('/\d/u', $suffix) === 1 && preg_match('/\d/u', $queryText) !== 1) { + $score -= 2; + } + } + + return $score; + } + + private function extractModelVariantSuffix(string $candidate): string + { + if (preg_match('/\b\d{2,5}[A-Za-z0-9\-]*\s+([A-Za-zÄÖÜäöüß]{2,8}\d{0,3}(?:\s+[A-Za-zÄÖÜäöüß]{2,8})?)\b/u', $candidate, $matches) === 1) { + return $this->sanitizeQuery((string) ($matches[1] ?? '')); + } + + if (preg_match('/\b\d{2,5}([A-Za-z]{1,8}\d{0,3})\b/u', $candidate, $matches) === 1) { + return $this->sanitizeQuery((string) ($matches[1] ?? '')); + } + + return ''; + } + + private function queryAlreadyContainsCandidate(string $queryText, string $candidate): bool + { + $queryTokens = array_fill_keys($this->tokenize($queryText), true); + $candidateTokens = $this->tokenize($candidate); + + if ($queryTokens === [] || $candidateTokens === []) { + return false; + } + + foreach ($candidateTokens as $token) { + if (!isset($queryTokens[$token])) { + return false; + } + } + + return true; + } + /** @param string[] $terms */ private function buildTokenSet(array $terms): array {