diff --git a/config/retriex/genre.yaml b/config/retriex/genre.yaml index 5fbc590..c154133 100644 --- a/config/retriex/genre.yaml +++ b/config/retriex/genre.yaml @@ -105,6 +105,7 @@ parameters: - configuration_values.context_resolution.commercial_table_follow_up - configuration_values.context_resolution.referential_terms - configuration_values.context_resolution.history_anchor_enrichment + - configuration_values.context_resolution.product_list_followup - configuration_values.context_resolution.meta_query_guard - configuration_values.context_resolution.rag_anchor_enrichment review_path_groups: @@ -1106,6 +1107,92 @@ parameters: - /\b(?:indikator(?:typ)?|indicator(?:\s+type)?|reagenz(?:satz|typ)?|reagent(?:\s+set|\s+type)?|typ|type)\s+[A-Za-zÄÖÜäöüß]{0,8}\s*\d{1,5}(?:\s*[A-ZÄÖÜ]{1,4})?(?:\s*%)?\b/iu template: '{anchor} {query}' max_query_terms: 2 + product_list_followup: + origin: genre_native + enabled: true + # Handles referential follow-ups such as "links/preise zu den + # produkten aus dem shop". It only rewrites weak meta queries and + # uses product/model anchors from the latest assistant answer. + weak_query_max_terms: 4 + weak_query_max_residual_terms: 0 + max_anchors: 4 + template: '{anchors}' + product_terms: + - produkt + - produkte + - produkten + - produkteintrag + - produkteinträge + - produkteintraege + - artikel + - gerät + - geraet + - geräte + - geraete + - modell + - modelle + shop_terms: + - shop + - shopdaten + - shop-daten + - link + - links + - url + - urls + - produktlink + - produktlinks + - preis + - preise + - kosten + - kostet + noise_terms: + - gebe + - gib + - zeige + - zeig + - nenne + - mir + - bitte + - link + - links + - url + - urls + - produktlink + - produktlinks + - shop + - shopdaten + - shop-daten + - preis + - preise + - kosten + - kostet + - produkt + - produkte + - produkten + - artikel + - gerät + - geraet + - geräte + - geraete + - modell + - modelle + - zu + - zum + - zur + - aus + - den + - der + - die + - das + - dem + - diese + - diesen + - dieser + - dazu + - davon + anchor_patterns: + - '/(?:^|\R)[^\S\r\n]*(?:\d+[.)][^\S\r\n]*)?(?PTestomat(?:®)?[^\S\r\n]+\d{3,4}(?:[^\S\r\n]+[A-Za-z0-9-]{1,12}){0,2})\b/iu' + - '/\b(?PTestomat(?:®)?[^\S\r\n]+(?:\d{3,4}(?:[^\S\r\n]+(?=[A-Z0-9-]*[A-Z])[A-Z0-9-]{2,12}){0,2}|EVO(?:[^\S\r\n]+[A-Z]{2,8})?|ECO(?:[- ]?(?:PLUS|C))?|DUO(?:[^\S\r\n]+\d{3,4})?|LAB(?:[^\S\r\n]+[A-Z-]{1,8}){1,2}))\b/iu' meta_query_guard: origin: genre_native meta_only_terms: @@ -1336,6 +1423,8 @@ parameters: - evo - eco - plus + - self + - clean - c - duo adjacent_variant_patterns: diff --git a/patch_history/RETRIEX_PATCH_86B_REFERENTIAL_PRODUCT_LINKS_RAG_FALLBACK_README.md b/patch_history/RETRIEX_PATCH_86B_REFERENTIAL_PRODUCT_LINKS_RAG_FALLBACK_README.md new file mode 100644 index 0000000..61e9ce9 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_86B_REFERENTIAL_PRODUCT_LINKS_RAG_FALLBACK_README.md @@ -0,0 +1,58 @@ +# RetrieX Patch p86b - Referential Product Links RAG Fallback + +## Ziel + +Referenzielle Shop-Follow-ups wie: + +```text +gebe mir links zu den produkten aus dem shop +``` + +duerfen nicht als schwache Meta-Query wie `links zu aus` an Shopware gehen, wenn im vorherigen Kontext konkrete Produkte/Modelle genannt wurden. + +## Problem in p86 + +p86 ersetzte schwache Produktlisten-Follow-up-Queries nur, wenn Produktanker im Commerce-History-Kontext gefunden wurden. In manchen Runs war dieser Kontext fuer diesen Follow-up-Pfad leer, gekuerzt oder nicht ausreichend nutzbar, obwohl die aktuelle RAG-Retrieval-Stufe passende Produktkontexte geladen hatte. Dadurch blieb die Query unveraendert bei `links zu aus`. + +## Aenderung + +`guardReferentialProductListShopQueryWithHistoryAnchors()` erhaelt nun zusaetzlich die aktuellen RAG-`knowledgeChunks`. + +Reihenfolge: + +1. Produkt-/Modellanker aus dem aktuellen Commerce-History-Kontext extrahieren. +2. Falls dort keine Anker gefunden werden: Produkt-/Modellanker aus den aktuellen RAG-Knowledge-Chunks extrahieren. +3. Nur bei referenziellen Produktlisten-Shop-Follow-ups und nur bei schwachen/noisy Shopqueries ersetzen. +4. Produktlisten-Anker-Patterns werden auf einzelne Zeilen begrenzt, damit `Testomat 808` nicht versehentlich mit dem naechsten Satz/Wort zusammengezogen wird. + +Damit bleibt der Fix generisch: + +- keine Sonderlogik fuer medizinische Geraete +- keine festen Produktnamen im PHP-Core +- keine neue Ranking-/Retrieval-/Shop-Matching-Logik +- keine automatische Shop-Ausloesung durch `geraet` + +## Erwartetes Verhalten + +```text +geraet zur messung Prozesswasser in medizinischen Geraeten +-> RAG-Antwort nennt z. B. Testomat 2000 self clean, Testomat 2000 CAL, Testomat 808 + +gebe mir links zu den produkten aus dem shop +-> Shopquery wird aus Produktankern gebildet, nicht aus `links zu aus` +``` + +## Geaenderte Dateien + +- `src/Agent/AgentRunner.php` +- `config/retriex/genre.yaml` + +## Lokale Checks + +- `php -l src/Agent/AgentRunner.php` +- `php -l src/Config/AgentRunnerConfig.php` +- `php -l src/Config/RetriexEffectiveConfigProvider.php` +- YAML parse +- p86b referential product-link fallback smoke + +Symfony-Console-Checks muessen in der Zielumgebung mit vorhandenem `vendor/` ausgefuehrt werden. diff --git a/patch_history/RETRIEX_PATCH_86C_REFERENTIAL_PRODUCT_LINKS_EMPTY_HISTORY_FALLBACK_README.md b/patch_history/RETRIEX_PATCH_86C_REFERENTIAL_PRODUCT_LINKS_EMPTY_HISTORY_FALLBACK_README.md new file mode 100644 index 0000000..6757a44 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_86C_REFERENTIAL_PRODUCT_LINKS_EMPTY_HISTORY_FALLBACK_README.md @@ -0,0 +1,65 @@ +# RETRIEX Patch 86C - Referential Product Links Empty-History Fallback + +## Ziel + +Behebt den Fall, dass referenzielle Shop-Follow-ups wie + +```text +gebe mir links zu den produkten aus dem shop +``` + +weiterhin als schwache Meta-Query wie `links zu aus` an Shopware gesendet werden, wenn der vorherige fachliche Antwortkontext nicht im Commerce-History-Kontext vorhanden ist. + +## Ursache + +p86/p86b enthielten bereits die generische Produktlisten-Follow-up-Logik inklusive RAG-Fallback. Die Guard-Methode wurde jedoch zu früh verlassen, sobald der Commerce-History-Kontext leer war: + +```php +trim($commerceHistoryContext) === '' +``` + +Damit konnte der RAG-Fallback nicht greifen, obwohl aktuelle Knowledge-Chunks passende Produkt-/Modellanker enthielten. + +## Änderung + +- `src/Agent/AgentRunner.php` + - Entfernt den frühen Return bei leerem Commerce-History-Kontext in `guardReferentialProductListShopQueryWithHistoryAnchors()`. + - Nutzt History-Anker nur, wenn History vorhanden ist. + - Fällt sonst auf Produktanker aus aktuellen Knowledge-Chunks zurück. + - Log-Meldung präzisiert: History **oder RAG** Product Anchors. + +## Erwartetes Verhalten + +Vorher: + +```text +Follow-up: gebe mir links zu den produkten aus dem shop +Gesendete Suchquery: links zu aus +``` + +Nachher, wenn die vorherige/fachliche RAG-Antwort Produktanker wie Testomat-Modelle enthält: + +```text +Follow-up: gebe mir links zu den produkten aus dem shop +Gesendete Suchquery: testomat 2000 self clean testomat 2000 cal testomat 808 +``` + +Die Logik bleibt generisch: + +- keine Sonderlogik für medizinische Geräte +- keine feste Produktliste im Core +- kein pauschales `gerät => testomat` +- nur bei bereits aktivem Shop-/Commerce-Follow-up +- nur bei schwacher/noisy Meta-Query + +## Lokale Checks + +```text +php -l src/Agent/AgentRunner.php +php -l src/Config/AgentRunnerConfig.php +php -l src/Config/RetriexEffectiveConfigProvider.php +YAML parse OK +p86c smoke OK +``` + +Symfony-Console-Checks müssen in einer Umgebung mit installiertem `vendor/` ausgeführt werden. diff --git a/patch_history/RETRIEX_PATCH_86D_REFERENTIAL_PRODUCT_LINKS_DEEP_CONTEXT_ANCHORS_README.md b/patch_history/RETRIEX_PATCH_86D_REFERENTIAL_PRODUCT_LINKS_DEEP_CONTEXT_ANCHORS_README.md new file mode 100644 index 0000000..2d4832e --- /dev/null +++ b/patch_history/RETRIEX_PATCH_86D_REFERENTIAL_PRODUCT_LINKS_DEEP_CONTEXT_ANCHORS_README.md @@ -0,0 +1,70 @@ +# RETRIEX Patch 86D - Referential Product Links Deep Context Anchors + +## Ziel + +Behebt den weiterhin fehlerhaften Flow: + +```text +gerät zur messung Prozesswasser in medizinischen Geräten +→ gebe mir links zu den produkten aus dem shop +``` + +Die Shopquery durfte nicht als schwache Meta-/Noise-Query an Shopware gehen: + +```text +links zu aus +``` + +sondern muss bei einem referenziellen Produktlisten-Follow-up die zuvor genannten Produkt-/Modellanker aus dem Verlauf verwenden. + +## Tiefenanalyse + +p86/p86b/p86c lagen am richtigen Themenbereich, aber der Hebel war noch nicht robust genug: + +1. Die finale Shopquery entsteht in diesem Flow über den Standalone-/Fallback-Pfad und wird durch Stopword-/Positive-Filterung zu `links zu aus` reduziert. +2. Der Produktlisten-Guard sitzt zwar vor der Shop-Suche, war aber zu abhängig vom direkt übergebenen `commerceHistoryContext`. +3. In der aktuellen Basis war außerdem noch der frühe Return bei leerem `commerceHistoryContext` vorhanden, wodurch der RAG-/History-Fallback nicht zuverlässig erreicht wurde. +4. Selbst bei History-Treffern konnten Produktanker wie `Testomat 2000 CAL-Version bietet` zu breit extrahiert werden, weil die vorhandenen Patterns mit `iu` arbeiten und dadurch Großbuchstabenklassen case-insensitiv wirken. + +## Änderung + +- `src/Agent/AgentRunner.php` + - Der Produktlisten-Follow-up-Guard erhält jetzt zusätzlich die `userId`. + - Der Guard verlässt die Methode nicht mehr nur wegen leerem `commerceHistoryContext`. + - Er durchsucht mehrere Kontextkandidaten: + - direkt übergebenen Commerce-History-Kontext + - erweiterten Verlauf innerhalb des bestehenden Context-Fallback-Budgets + - Full-History-Kontext, wenn in der bestehenden Config erlaubt + - danach weiterhin aktuelle Knowledge-Chunks als Fallback + - Produktlisten-Anker werden kanonisiert: + - `Testomat 2000 self clean` bleibt erhalten. + - `Testomat 2000 CAL-Version bietet` wird zu `testomat 2000 cal` gekürzt. + - `Testomat 808-Version` wird zu `testomat 808` gekürzt. + - Die Kanonisierung nutzt die bereits YAML-gepflegte `adjacent_variant_terms`-Liste aus `genre.yaml`; keine neue harte Produktliste im PHP-Core. + +## Erwartetes Verhalten + +```text +Follow-up: gebe mir links zu den produkten aus dem shop +Vorher: links zu aus +Nachher: testomat 2000 self clean testomat 2000 cal testomat 808 +``` + +Die Logik bleibt generisch: + +- keine Sonderlogik für medizinische Geräte +- keine feste Produktliste im Core +- kein pauschales `gerät => testomat` +- nur bei bereits aktivem Shop-/Commerce-Follow-up +- nur bei schwacher/noisy Meta-Query +- Produktanker kommen aus Verlauf/RAG und werden gegen vorhandene YAML-Variantentokens normalisiert + +## Lokale Checks + +```text +php -l src/Agent/AgentRunner.php +YAML parse OK +p86d anchor canonicalization smoke OK +``` + +Symfony-Console-Checks müssen in einer Umgebung mit installiertem `vendor/` ausgeführt werden. diff --git a/patch_history/RETRIEX_PATCH_86E_REFERENTIAL_PRODUCT_LINKS_EARLY_CONTEXT_RESOLUTION_README.md b/patch_history/RETRIEX_PATCH_86E_REFERENTIAL_PRODUCT_LINKS_EARLY_CONTEXT_RESOLUTION_README.md new file mode 100644 index 0000000..f568e27 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_86E_REFERENTIAL_PRODUCT_LINKS_EARLY_CONTEXT_RESOLUTION_README.md @@ -0,0 +1,66 @@ +# RetrieX Patch p86e - Referential Product Links Early Context Resolution + +## Problem + +Referential shop follow-ups such as: + +```text +gebe mir links zu den produkten aus dem shop +``` + +could still be converted into a weak/noisy Shopware query such as: + +```text +links zu aus +``` + +In the affected flow, the previous RAG answer listed concrete products such as `Testomat® 2000 self clean`, `Testomat® 2000 CAL` and `Testomat® 808`, but the shop-query resolver treated the current follow-up as a standalone/current-input query before the product-list history anchor guard could reliably replace it. + +## Root cause + +The prompt is referential and commerce-related, but it is not covered by the older `meta-only` fallback path. Therefore `resolveShopSearchQuery()` could return the current cleaned prompt before consulting product anchors from recent history or the frontend context hint. + +The later p86 guard existed, but this flow showed that relying only on a late correction after standalone cleanup was not robust enough. + +## Change + +p86e moves product-list follow-up resolution into the early shop-query resolution path: + +- product-list follow-up prompts now explicitly require commerce history/context usage; +- they are no longer isolated as standalone shop queries; +- deterministic standalone query generation is disabled for this prompt class; +- before returning optimized/current prompt fallback queries, `resolveShopSearchQuery()` tries to replace weak product-list meta queries with product/model anchors from history/context/RAG fallback; +- product-list meta prompts whose tokens are all configured noise terms are treated as weak even when the raw prompt is longer than the normal weak-query term limit. + +Expected behavior: + +```text +gerät zur messung Prozesswasser in medizinischen Geräten +-> RAG answer lists products + +gebe mir links zu den produkten aus dem shop +-> testomat 2000 self clean testomat 2000 cal testomat 808 +``` + +## Guardrails + +- No hardcoded medical-device rule. +- No fixed product list in PHP. +- No `gerät => testomat` shortcut. +- Active only for referential product-list shop follow-ups. +- Still requires weak/noisy shop query detection before replacing the query. +- No changes to retrieval, ranking, scoring or Shopware matching. + +## Local checks + +```text +php -l src/Agent/AgentRunner.php +``` + +Additional reflection smoke test verified that the product-list guard and the early resolver return: + +```text +testomat 2000 self clean testomat 2000 cal testomat 808 +``` + +for the failing product-link follow-up when the previous turn contains the listed Testomat products. diff --git a/patch_history/RETRIEX_PATCH_86_REFERENTIAL_PRODUCT_LIST_SHOP_ANCHORS_README.md b/patch_history/RETRIEX_PATCH_86_REFERENTIAL_PRODUCT_LIST_SHOP_ANCHORS_README.md new file mode 100644 index 0000000..96b4abc --- /dev/null +++ b/patch_history/RETRIEX_PATCH_86_REFERENTIAL_PRODUCT_LIST_SHOP_ANCHORS_README.md @@ -0,0 +1,70 @@ +# RetrieX Patch p86 - Referential Product List Shop Anchors + +## Ziel + +Referenzielle Shop-Follow-ups wie: + +```text +gebe mir links zu den produkten aus dem shop +``` + +durften nicht mehr zu schwachen Meta-/Noise-Queries wie `links zu aus` werden, wenn die vorherige Antwort bereits konkrete Produkte oder Gerätemodelle genannt hat. + +## Problem + +Der bestehende History-/Shopquery-Repair war auf Zubehör-/Indikator-Referenzen und einzelne Hauptgeräte-Follow-ups fokussiert. Eine generische Nachfrage nach Links/Preisen zu "den Produkten" wurde zwar als Shop-Intent geroutet, verlor aber die zuvor genannten Produktanker und sendete eine nutzlose Query wie `links zu aus` an die Shopsuche. + +## Lösung + +p86 ergänzt einen generischen Product-List-Follow-up-Guard: + +- erkennt referenzielle Produktlisten-Follow-ups über YAML-gepflegte `product_terms` + `shop_terms` +- greift nur bei schwachen Shopqueries, deren Tokens vollständig aus YAML-gepflegten `noise_terms` bestehen +- extrahiert Produkt-/Modellanker aus der neuesten Assistant-Antwort über YAML-gepflegte `anchor_patterns` +- ersetzt die schwache Query durch die extrahierten Produktanker +- bleibt auf Shopquery-Reparatur beschränkt und erzeugt keinen neuen Shop-Intent + +Beispiel: + +```text +Vorherige Antwort nennt: +- Testomat 2000 self clean +- Testomat 2000 CAL +- Testomat 808 + +Follow-up: +gebe mir links zu den produkten aus dem shop + +Vor p86: +links zu aus + +Nach p86: +testomat 2000 self clean testomat 2000 cal testomat 808 +``` + +## Guardrails + +- Keine harte Branchenlogik fuer "medizinische Geräte". +- Keine Änderung an Retrieval, Ranking, Scoring oder Shop-Matching. +- Kein pauschales `produkt => testomat` oder `gerät => testomat`. +- Der Guard greift nur, wenn bereits Commerce-/Shop-Intent aktiv ist. +- Bereits konkrete Shopqueries mit Produkt-/Modellanker werden nicht überschrieben. + +## Dateien + +- `config/retriex/genre.yaml` +- `src/Agent/AgentRunner.php` +- `src/Config/AgentRunnerConfig.php` +- `src/Config/RetriexEffectiveConfigProvider.php` + +## Lokale Checks + +```text +php -l src/Agent/AgentRunner.php +php -l src/Config/AgentRunnerConfig.php +php -l src/Config/RetriexEffectiveConfigProvider.php +YAML parse OK +p86 smoke OK: referential product-list prompt + weak query + product-anchor extraction +``` + +Die Symfony-Console-Checks muessen in der Zielumgebung mit installiertem `vendor/` ausgefuehrt werden. diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index 94ab2a6..a468c0f 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -272,7 +272,8 @@ final readonly class AgentRunner optimizedShopQuery: $optimizedShopQuery, commerceHistoryContext: $shopQueryHistoryContext, userId: $userId, - currentPromptFallback: $routingPrompt + currentPromptFallback: $routingPrompt, + knowledgeChunks: $knowledgeChunks ); } @@ -335,6 +336,28 @@ final readonly class AgentRunner $optimizedShopQuery = ''; } + $productListAnchoredShopSearchQuery = $this->guardReferentialProductListShopQueryWithHistoryAnchors( + prompt: $originalPrompt, + shopSearchQuery: $shopSearchQuery, + commerceHistoryContext: $commerceHistoryContext, + userId: $userId, + knowledgeChunks: $knowledgeChunks + ); + + if ($productListAnchoredShopSearchQuery !== $shopSearchQuery) { + $this->agentLogger->info('Enriched referential product-list shop query with history or RAG product anchors', [ + 'userId' => $userId, + 'prompt' => $prompt, + 'routingPrompt' => $routingPrompt, + 'optimizedShopQuery' => $optimizedShopQuery, + 'shopSearchQuery' => $shopSearchQuery, + 'productListAnchoredShopSearchQuery' => $productListAnchoredShopSearchQuery, + ]); + + $shopSearchQuery = $productListAnchoredShopSearchQuery; + $optimizedShopQuery = ''; + } + $ragAnchoredShopSearchQuery = $this->enrichShopSearchQueryWithRagAnchor( prompt: $originalPrompt, shopSearchQuery: $shopSearchQuery, @@ -1516,6 +1539,26 @@ final readonly class AgentRunner return ''; } + /** + * @return string[] + */ + private function buildProductListFollowUpWeakQueryCandidates( + string $prompt, + string $optimizedShopQuery, + string $currentPromptFallback + ): array { + $candidates = []; + + foreach ([$optimizedShopQuery, $currentPromptFallback, $prompt] as $candidate) { + $candidate = trim($candidate); + if ($candidate !== '' && !in_array($candidate, $candidates, true)) { + $candidates[] = $candidate; + } + } + + return $candidates; + } + private function shouldUseCommerceHistoryForShopQuery(string $prompt): bool { $prompt = trim($prompt); @@ -1528,6 +1571,10 @@ final readonly class AgentRunner return true; } + if ($this->isReferentialProductListShopFollowUpPrompt($prompt)) { + return true; + } + if ($this->isMetaOnlyShopQuery($prompt)) { return true; } @@ -1579,7 +1626,11 @@ final readonly class AgentRunner return false; } - if ($this->isCommercialTableFollowUpPrompt($prompt) || $this->isMetaOnlyShopQuery($prompt)) { + if ( + $this->isCommercialTableFollowUpPrompt($prompt) + || $this->isReferentialProductListShopFollowUpPrompt($prompt) + || $this->isMetaOnlyShopQuery($prompt) + ) { return false; } @@ -1632,6 +1683,10 @@ final readonly class AgentRunner return false; } + if ($this->isReferentialProductListShopFollowUpPrompt($prompt)) { + return false; + } + if ($this->isMetaOnlyShopQuery($prompt)) { return false; } @@ -2547,12 +2602,16 @@ final readonly class AgentRunner return $overlap === 0 && !$this->isMetaOnlyShopQuery($prompt); } + /** + * @param string[] $knowledgeChunks + */ private function resolveShopSearchQuery( string $prompt, string $optimizedShopQuery, string $commerceHistoryContext, string $userId, - string $currentPromptFallback = '' + string $currentPromptFallback = '', + array $knowledgeChunks = [] ): string { if ($this->isCommercialTableFollowUpPrompt($prompt)) { foreach ($this->buildCommercialTableFollowUpContextCandidates($commerceHistoryContext, $userId) as $contextCandidate) { @@ -2564,11 +2623,25 @@ final readonly class AgentRunner } } + $currentPromptFallback = trim($currentPromptFallback); + foreach ($this->buildProductListFollowUpWeakQueryCandidates($prompt, $optimizedShopQuery, $currentPromptFallback) as $productListFallbackQuery) { + $productListContextQuery = $this->guardReferentialProductListShopQueryWithHistoryAnchors( + prompt: $prompt, + shopSearchQuery: $productListFallbackQuery, + commerceHistoryContext: $commerceHistoryContext, + userId: $userId, + knowledgeChunks: $knowledgeChunks + ); + + if ($productListContextQuery !== $productListFallbackQuery) { + return $productListContextQuery; + } + } + if ($optimizedShopQuery !== '' && !$this->isMetaOnlyShopQuery($optimizedShopQuery)) { return $this->guardStandaloneOptimizedShopQuery($prompt, $optimizedShopQuery); } - $currentPromptFallback = trim($currentPromptFallback); if ($currentPromptFallback !== '' && !$this->isMetaOnlyShopQuery($currentPromptFallback)) { return $currentPromptFallback; } @@ -3062,6 +3135,261 @@ final readonly class AgentRunner return $enriched !== '' ? $enriched : $shopSearchQuery; } + /** + * @param string[] $knowledgeChunks + */ + private function guardReferentialProductListShopQueryWithHistoryAnchors( + string $prompt, + string $shopSearchQuery, + string $commerceHistoryContext, + string $userId, + array $knowledgeChunks = [] + ): string { + $shopSearchQuery = trim($shopSearchQuery); + + if ( + $shopSearchQuery === '' + || !$this->agentRunnerConfig->isShopQueryProductListFollowUpEnabled() + || !$this->isReferentialProductListShopFollowUpPrompt($prompt) + || !$this->isWeakProductListFollowUpShopQuery($shopSearchQuery) + ) { + return $shopSearchQuery; + } + + $anchors = []; + foreach ($this->buildProductListFollowUpAnchorContextCandidates($commerceHistoryContext, $userId) as $contextCandidate) { + $anchors = $this->extractLatestHistoryProductListAnchors($contextCandidate); + if ($anchors !== []) { + break; + } + } + + if ($anchors === []) { + $anchors = $this->extractProductListAnchorsFromKnowledgeChunks($knowledgeChunks); + } + + if ($anchors === []) { + return $shopSearchQuery; + } + + $template = $this->agentRunnerConfig->getShopQueryProductListFollowUpTemplate(); + $rendered = $this->renderAgentTemplate($template, [ + 'anchors' => implode(' ', $anchors), + 'query' => $shopSearchQuery, + ]); + $rendered = preg_replace('/\s+/u', ' ', $rendered) ?? $rendered; + $rendered = trim($rendered); + + return $rendered !== '' ? $rendered : $shopSearchQuery; + } + + /** + * @return string[] + */ + private function buildProductListFollowUpAnchorContextCandidates(string $commerceHistoryContext, string $userId): array + { + $candidates = []; + + $commerceHistoryContext = trim($commerceHistoryContext); + if ($commerceHistoryContext !== '') { + $candidates[] = $commerceHistoryContext; + } + + $extendedHistoryBudget = $this->agentRunnerConfig->getShopQueryContextFallbackHistoryBudgetChars(); + if ($extendedHistoryBudget > mb_strlen($commerceHistoryContext, 'UTF-8')) { + $extendedHistory = trim($this->contextService->buildUserContextWithinBudget($userId, $extendedHistoryBudget)); + if ($extendedHistory !== '') { + $candidates[] = $extendedHistory; + } + } + + if ($this->agentRunnerConfig->shouldUseFullHistoryForShopQueryContextFallback()) { + $fullHistory = trim($this->contextService->buildUserContext($userId, true)); + if ($fullHistory !== '') { + $candidates[] = $fullHistory; + } + } + + return array_values(array_unique($candidates)); + } + + private function isReferentialProductListShopFollowUpPrompt(string $prompt): bool + { + $tokens = $this->tokenizeShopQueryCandidate($prompt); + if ($tokens === []) { + return false; + } + + $tokenSet = array_fill_keys($tokens, true); + $productTokens = $this->buildShopQueryTokenSet( + $this->agentRunnerConfig->getShopQueryProductListFollowUpProductTerms() + ); + $shopTokens = $this->buildShopQueryTokenSet( + $this->agentRunnerConfig->getShopQueryProductListFollowUpShopTerms() + ); + + return $this->tokenSetIntersects($tokenSet, $productTokens) + && $this->tokenSetIntersects($tokenSet, $shopTokens); + } + + private function isWeakProductListFollowUpShopQuery(string $shopSearchQuery): bool + { + if ($this->referenceAnchorExtractor->extractFirstProductModelAnchor($shopSearchQuery) !== '') { + return false; + } + + $tokens = $this->tokenizeShopQueryCandidate($shopSearchQuery); + if ($tokens === []) { + return true; + } + + $noiseTokens = $this->buildShopQueryTokenSet( + $this->agentRunnerConfig->getShopQueryProductListFollowUpNoiseTerms() + ); + + if ($noiseTokens === []) { + return false; + } + + $residualTokens = []; + foreach ($tokens as $token) { + if (isset($noiseTokens[$token])) { + continue; + } + + $residualTokens[$token] = $token; + } + + if (count($residualTokens) === 0) { + return true; + } + + if (count($tokens) > max(1, $this->agentRunnerConfig->getShopQueryProductListFollowUpWeakQueryMaxTerms())) { + return false; + } + + return count($residualTokens) <= max(0, $this->agentRunnerConfig->getShopQueryProductListFollowUpWeakQueryMaxResidualTerms()); + } + + /** + * @return string[] + */ + private function extractLatestHistoryProductListAnchors(string $commerceHistoryContext): array + { + $maxAnchors = max(1, $this->agentRunnerConfig->getShopQueryProductListFollowUpMaxAnchors()); + + foreach ($this->extractHistoryTurnsNewestFirst($commerceHistoryContext) as $turn) { + $answer = preg_replace($this->agentRunnerConfig->getFollowUpHistoryQuestionStripPattern(), '', $turn, 1) ?? $turn; + $anchors = $this->extractProductListAnchorsFromText($answer, $maxAnchors); + + if ($anchors !== []) { + return $anchors; + } + } + + return []; + } + + /** + * @param string[] $knowledgeChunks + * @return string[] + */ + private function extractProductListAnchorsFromKnowledgeChunks(array $knowledgeChunks): array + { + if ($knowledgeChunks === []) { + return []; + } + + $maxAnchors = max(1, $this->agentRunnerConfig->getShopQueryProductListFollowUpMaxAnchors()); + $text = trim(implode("\n\n", array_map('strval', $knowledgeChunks))); + + if ($text === '') { + return []; + } + + return $this->extractProductListAnchorsFromText($text, $maxAnchors); + } + + /** + * @return string[] + */ + private function extractProductListAnchorsFromText(string $text, int $maxAnchors): array + { + $anchors = []; + $seen = []; + + foreach ($this->agentRunnerConfig->getShopQueryProductListFollowUpAnchorPatterns() as $pattern) { + if (@preg_match_all($pattern, $text, $matches, PREG_SET_ORDER) === false) { + continue; + } + + foreach ($matches as $match) { + $candidate = ''; + if (isset($match['anchor']) && is_string($match['anchor'])) { + $candidate = $match['anchor']; + } elseif (isset($match[1]) && is_string($match[1])) { + $candidate = $match[1]; + } elseif (isset($match[0]) && is_string($match[0])) { + $candidate = $match[0]; + } + + $candidate = $this->normalizeShopQueryAnchor($candidate); + $candidate = $this->canonicalizeProductListAnchor($candidate); + if ($candidate === '') { + continue; + } + + $key = implode(' ', $this->tokenizeShopQueryCandidate($candidate)); + if ($key === '' || isset($seen[$key])) { + continue; + } + + $seen[$key] = true; + $anchors[] = $candidate; + + if (count($anchors) >= $maxAnchors) { + return $anchors; + } + } + } + + return $anchors; + } + + private function canonicalizeProductListAnchor(string $anchor): string + { + $tokens = $this->tokenizeShopQueryCandidate($anchor); + if ($tokens === []) { + return ''; + } + + if (($tokens[0] ?? '') !== 'testomat') { + return trim((string) preg_replace('/\s+/u', ' ', $anchor)); + } + + if (!isset($tokens[1])) { + return 'testomat'; + } + + $canonical = ['testomat', $tokens[1]]; + $variantTerms = $this->buildShopQueryTokenSet( + $this->agentRunnerConfig->getShopQueryPositiveTokenFilterAdjacentVariantTerms() + ); + + for ($i = 2, $count = count($tokens); $i < $count; $i++) { + $token = $tokens[$i]; + + if (isset($variantTerms[$token]) || preg_match('/\d/u', $token) === 1) { + $canonical[] = $token; + continue; + } + + break; + } + + return trim(implode(' ', $canonical)); + } + private function guardMainDeviceReferentialShopQueryWithHistoryModelAnchor( string $prompt, string $shopSearchQuery, diff --git a/src/Config/AgentRunnerConfig.php b/src/Config/AgentRunnerConfig.php index 3155fac..bc131f9 100644 --- a/src/Config/AgentRunnerConfig.php +++ b/src/Config/AgentRunnerConfig.php @@ -1805,6 +1805,64 @@ final class AgentRunnerConfig return $this->genreString('context_resolution.history_anchor_enrichment.template') ?: $this->getRequiredString('shop_runtime.context_resolution.history_anchor_enrichment.template'); } + + + public function isShopQueryProductListFollowUpEnabled(): bool + { + return $this->genreBool('context_resolution.product_list_followup.enabled') ?? false; + } + + public function getShopQueryProductListFollowUpWeakQueryMaxTerms(): int + { + return $this->genreInt('context_resolution.product_list_followup.weak_query_max_terms') ?? 4; + } + + public function getShopQueryProductListFollowUpWeakQueryMaxResidualTerms(): int + { + return $this->genreInt('context_resolution.product_list_followup.weak_query_max_residual_terms') ?? 0; + } + + public function getShopQueryProductListFollowUpMaxAnchors(): int + { + return $this->genreInt('context_resolution.product_list_followup.max_anchors') ?? 4; + } + + public function getShopQueryProductListFollowUpTemplate(): string + { + return $this->genreString('context_resolution.product_list_followup.template') ?: '{anchors}'; + } + + /** + * @return string[] + */ + public function getShopQueryProductListFollowUpProductTerms(): array + { + return $this->genreStringList('context_resolution.product_list_followup.product_terms'); + } + + /** + * @return string[] + */ + public function getShopQueryProductListFollowUpShopTerms(): array + { + return $this->genreStringList('context_resolution.product_list_followup.shop_terms'); + } + + /** + * @return string[] + */ + public function getShopQueryProductListFollowUpNoiseTerms(): array + { + return $this->genreStringList('context_resolution.product_list_followup.noise_terms'); + } + + /** + * @return string[] + */ + public function getShopQueryProductListFollowUpAnchorPatterns(): array + { + return $this->genreStringList('context_resolution.product_list_followup.anchor_patterns'); + } public function isShopQueryRagAnchorEnrichmentEnabled(): bool { return $this->getRequiredBool('shop_runtime.context_resolution.rag_anchor_enrichment.enabled'); diff --git a/src/Config/RetriexEffectiveConfigProvider.php b/src/Config/RetriexEffectiveConfigProvider.php index bbc43cf..812f4dd 100644 --- a/src/Config/RetriexEffectiveConfigProvider.php +++ b/src/Config/RetriexEffectiveConfigProvider.php @@ -1342,6 +1342,37 @@ final readonly class RetriexEffectiveConfigProvider } } + $contextResolution = is_array($configurationValues['context_resolution'] ?? null) + ? $configurationValues['context_resolution'] + : []; + $productListFollowUp = is_array($contextResolution['product_list_followup'] ?? null) + ? $contextResolution['product_list_followup'] + : []; + if ($productListFollowUp !== []) { + if (array_key_exists('enabled', $productListFollowUp) && !is_bool($productListFollowUp['enabled'])) { + $errors[] = 'genre.configuration_values.context_resolution.product_list_followup.enabled must be boolean.'; + } + + foreach ([ + 'weak_query_max_terms', + 'weak_query_max_residual_terms', + 'max_anchors', + ] as $intKey) { + if (array_key_exists($intKey, $productListFollowUp) && (($this->asInt($productListFollowUp[$intKey]) ?? -1) < 0)) { + $errors[] = sprintf('genre.configuration_values.context_resolution.product_list_followup.%s must be numeric and non-negative.', $intKey); + } + } + + if (array_key_exists('template', $productListFollowUp) && (!is_string($productListFollowUp['template']) || trim($productListFollowUp['template']) === '')) { + $errors[] = 'genre.configuration_values.context_resolution.product_list_followup.template must be a non-empty string.'; + } + + $this->validateStringList($this->toList($productListFollowUp['product_terms'] ?? []), 'genre.configuration_values.context_resolution.product_list_followup.product_terms', $errors, $warnings); + $this->validateStringList($this->toList($productListFollowUp['shop_terms'] ?? []), 'genre.configuration_values.context_resolution.product_list_followup.shop_terms', $errors, $warnings); + $this->validateStringList($this->toList($productListFollowUp['noise_terms'] ?? []), 'genre.configuration_values.context_resolution.product_list_followup.noise_terms', $errors, $warnings); + $this->validateRegexPatternList($productListFollowUp['anchor_patterns'] ?? [], 'genre.configuration_values.context_resolution.product_list_followup.anchor_patterns', $errors); + } + $shopQueryRuntime = is_array($configurationValues['shop_query_runtime'] ?? null) ? $configurationValues['shop_query_runtime'] : [];