diff --git a/config/retriex/chat-messages.yaml b/config/retriex/chat-messages.yaml index 76dce64..dbc305e 100644 --- a/config/retriex/chat-messages.yaml +++ b/config/retriex/chat-messages.yaml @@ -49,7 +49,7 @@ parameters: assistant: loader: 'Antwort wird vorbereitet…' aborted: '[aborted]' - history_cleared: 'History cleared.' + history_cleared: 'Chat-History wurde gelöscht.' source_chips: live_shop_data: 'Live-Shopdaten' run_meta: @@ -209,6 +209,7 @@ parameters: shop_meta_repair_checked: 'Erweiterte Suche: geprüft' shop_meta_eyebrow: Shop-Suche shop_meta_query_label: Gesendete Suchquery + shop_meta_queries_label: Gesendete Einzelqueries shop_meta_query_prefix: 'Query: ' shop_meta_intent_prefix: 'Intent: ' shop_unavailable_default_reason: Keine Detailmeldung vom Shopware-Server. diff --git a/config/retriex/genre.yaml b/config/retriex/genre.yaml index c154133..8262534 100644 --- a/config/retriex/genre.yaml +++ b/config/retriex/genre.yaml @@ -1193,6 +1193,10 @@ parameters: 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' + canonical_start_patterns: + - '/\b(?PTestomat(?:®)?\b.*)$/iu' + canonical_family_terms: + - testomat meta_query_guard: origin: genre_native meta_only_terms: diff --git a/patch_history/RETRIEX_PATCH_86H_MULTI_PRODUCT_FOLLOWUP_SPLIT_LOOKUP_README.md b/patch_history/RETRIEX_PATCH_86H_MULTI_PRODUCT_FOLLOWUP_SPLIT_LOOKUP_README.md new file mode 100644 index 0000000..581f71f --- /dev/null +++ b/patch_history/RETRIEX_PATCH_86H_MULTI_PRODUCT_FOLLOWUP_SPLIT_LOOKUP_README.md @@ -0,0 +1,79 @@ +# RetrieX Patch p86h - Multi Product Follow-up Split Lookup Completion + +## Ziel + +p86e/p86g konnten referenzielle Produktlisten-Follow-ups bereits weg von schwachen Meta-Queries wie `links zu aus` auf Produktanker aus dem Verlauf aufloesen. In einem Multi-Produkt-Fall blieb aber eine kombinierte Shopquery sichtbar und teilweise wirksam, z. B.: + +```text +testomat 2000 cal 808 +``` + +Diese kombinierte Query kann nur einen Teil der gemeinten Produkte treffen oder ueber Beschreibungen/Zubehoer unpassende Treffer liefern. + +## Problem + +p86g pruefte zwar die Produktidentitaet, stoppte aber sobald mindestens ein passender Treffer vorhanden war. Dadurch wurden fehlende Verlaufanker nicht separat nachgesucht. + +Beispiel: + +```text +Vorherige Antwort nennt: +- Testomat 2000 CAL +- Testomat 808 + +Follow-up: +gebe mir links zu den produkten aus dem shop + +Primaere Query: +testomat 2000 cal 808 +``` + +Wenn die primaere Query nur `Testomat 2000 CAL` fand, wurde nicht mehr separat nach `testomat 808` gesucht. + +## Loesung + +p86h erweitert die p86g-Logik: + +1. Bestehende Shop-Treffer werden weiterhin gegen die Produktidentitaeten der Verlaufanker gefiltert. +2. Danach wird ermittelt, welche Verlaufanker bereits abgedeckt sind. +3. Wenn bei mehreren Ankern mindestens einer fehlt, werden die Produktanker separat nachgesucht. +4. Bereits gefundene Identitaetstreffer und separate Anchor-Lookup-Treffer werden dedupliziert gemerged. +5. Die abgeschlossene Shop-Meta-Anzeige zeigt bei referenziellen Produktlisten-Follow-ups die verwendeten Anchor-Queries, z. B.: + +```text +testomat 2000 cal | testomat 808 +``` + +## Guardrails + +Der Patch ist bewusst eng begrenzt: + +- nur referenzielle Produktlisten-/Shoplink-Follow-ups +- keine neue Intent-Erkennung +- keine Aenderung an Retrieval, Ranking, Scoring oder Shop-Matching +- keine harte Produktliste im Core +- keine Sonderlogik fuer medizinische Geraete +- p84 LAB-CL-Kuerzel, p85b SIO2-Anker und Accessory-/Indicator-Flows bleiben unberuehrt + +## Geaenderte Dateien + +- `src/Agent/AgentRunner.php` +- `patch_history/RETRIEX_PATCH_86H_MULTI_PRODUCT_FOLLOWUP_SPLIT_LOOKUP_README.md` + +## Lokale Checks + +```text +php -l src/Agent/AgentRunner.php +find src -name '*.php' -print0 | xargs -0 -n1 php -l +YAML parse OK +vendor missing - console checks skipped +``` + +Console-Checks muessen in einer Umgebung mit `vendor/` ausgefuehrt werden: + +```text +php bin/console mto:agent:config:validate +php bin/console mto:agent:regression:test +php bin/console mto:agent:config:audit-source --details +php bin/console mto:agent:config:audit-patterns --details +``` diff --git a/patch_history/RETRIEX_PATCH_86J_MULTI_PRODUCT_SPLIT_QUERY_META_CLARITY_README.md b/patch_history/RETRIEX_PATCH_86J_MULTI_PRODUCT_SPLIT_QUERY_META_CLARITY_README.md new file mode 100644 index 0000000..ec48277 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_86J_MULTI_PRODUCT_SPLIT_QUERY_META_CLARITY_README.md @@ -0,0 +1,68 @@ +# RetrieX Patch p86j - Multi Product Split Query Meta Clarity + +## Ziel + +Referenzielle Produktlisten-Follow-ups wie: + +```text +gebe mir links zu den produkten aus dem shop +``` + +koennen seit p86i mehrere Produktanker als getrennte Shopware-Suchen ausfuehren. Die UI zeigte diese Einzelqueries jedoch weiterhin als zusammengefuehrte Semikolon-Zeile unter `Gesendete Suchquery`, z. B.: + +```text +testomat 2000 self clean; testomat 2000 cal; testomat 808 +``` + +Das war missverstaendlich, weil die Shopware-API keine Pipe-/Semikolon-Syntax als einzelne Query versteht. + +## Aenderung + +- Split-Produktlisten-Follow-ups werden in der Shop-Meta-Card nun als `Gesendete Einzelqueries` dargestellt. +- Jede Einzelquery wird separat gerendert, statt zu einer Semikolon- oder Pipe-Query zusammengefuehrt zu werden. +- Die technische Ausfuehrung aus p86i bleibt erhalten: `searchProductListFollowUpSplitLookupQueries()` fuehrt weiterhin einzelne, begrenzte und sequenzielle `searchShop()`-Aufrufe pro Produktanker aus. +- Die bestehende semikolonbasierte Display-Formatierung wurde entfernt, damit sie nicht versehentlich wieder als Shopware-Query interpretiert wird. +- Die Commerce-Search-Logs enthalten nun zusaetzlich `individualShopQueries`, wenn Split-Lookups vorbereitet wurden. +- Der einzelne Referenzanker-Guard wird bei bereits identitaetsgefilterten Split-Lookup-Ergebnissen uebersprungen, damit ein Treffer zu `Testomat 2000 self clean` nicht spaeter `Testomat 2000 CAL` oder `Testomat 808` aus der zusammengefuehrten Anzeige-Query herausfiltert. + +## Bewusst nicht geaendert + +- Keine Async-/Parallelisierung. Der Store-API-Pfad ist synchron; Async waere ein separater, groesserer Service-Umbau. +- Keine Aenderung an Intent-Erkennung, Retrieval, Scoring, Ranking oder Shop-Matching. +- Keine neue Produkt-/Marken-Sonderlogik. +- Keine Veraenderung normaler Einzel-Shopqueries. + +## Erwartung + +Vorher: + +```text +Gesendete Suchquery +testomat 2000 self clean; testomat 2000 cal; testomat 808 +``` + +Nachher: + +```text +Gesendete Einzelqueries +testomat 2000 self clean +testomat 2000 cal +testomat 808 +``` + +## Geaenderte Dateien + +- `src/Agent/AgentRunner.php` +- `config/retriex/chat-messages.yaml` +- `src/Config/ChatMessagesConfig.php` +- `public/assets/styles/base.css` + +## Lokale Checks + +```text +find src -name '*.php' -print0 | xargs -0 -n1 php -l +python yaml parse for config/retriex/*.yaml +static check: no semicolon display formatter remains +``` + +Alle lokalen Checks waren gruen. Symfony-Console-Checks muessen in der Zielumgebung mit `vendor/` ausgefuehrt werden. diff --git a/patch_history/RETRIEX_PATCH_86K_YAML_BACKED_PRODUCT_ANCHOR_CANONICALIZATION_README.md b/patch_history/RETRIEX_PATCH_86K_YAML_BACKED_PRODUCT_ANCHOR_CANONICALIZATION_README.md new file mode 100644 index 0000000..6c7ebe5 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_86K_YAML_BACKED_PRODUCT_ANCHOR_CANONICALIZATION_README.md @@ -0,0 +1,77 @@ +# RetrieX Patch p86k - YAML-backed Product Anchor Canonicalization + +## Zweck + +p86j hat die Multi-Product-Link-Follow-up-Logik verbessert, aber der Core-Pattern-Audit meldete danach einen Warnfund in `src/Agent/AgentRunner.php`: + +```text +preg_match('/\btestomat(?:®)?\b.*$/iu', $anchor, $match) +``` + +p86k entfernt dieses domain-sensitive Pattern aus dem PHP-Core und verlagert die fachliche Start-/Familien-Erkennung in `config/retriex/genre.yaml`. + +## Änderung + +- `AgentRunner::canonicalizeProductListAnchor()` nutzt keine hart codierte Produktfamilie mehr. +- Neuer YAML-backed Trim-Schritt `trimProductListAnchorToConfiguredStart()`: + - liest `context_resolution.product_list_followup.canonical_start_patterns` + - akzeptiert Named Group `anchor`, Fallback auf Gruppe 1 oder Full Match +- Produktfamilien-Erkennung liest nun aus: + - `context_resolution.product_list_followup.canonical_family_terms` +- `AgentRunnerConfig` stellt Accessor-Methoden bereit. +- `RetriexEffectiveConfigProvider` validiert die neuen YAML-Werte. + +## Keine fachliche Änderung + +Der Patch ändert nicht: + +- Intent-Erkennung +- Retrieval +- Scoring +- Ranking +- Shop-Matching +- Split-Request-Logik aus p86i/p86j +- LAB-CL-Kürzelschutz aus p84 +- SiO2-Geräteanker aus p85b + +## Erwartung + +Der bisherige Effekt bleibt erhalten: + +```text +Systembeschreibung Der Testomat® 2000 CAL +=> testomat 2000 cal +``` + +Die dafür nötige Produktfamilienlogik liegt nun in YAML statt im PHP-Core. + +## Lokale Checks + +Ausgeführt im Patch-Arbeitsverzeichnis: + +```bash +find src -name '*.php' -print0 | xargs -0 -n1 php -l +python3 - <<'PY' +from pathlib import Path +import yaml +for path in Path('config/retriex').glob('*.yaml'): + yaml.safe_load(path.read_text(encoding='utf-8')) +print('YAML parse OK') +PY +grep -RIn "preg_match.*testomat\|testomat.*preg_match" src/Agent/AgentRunner.php +``` + +Ergebnis: + +- PHP lint OK +- YAML parse OK +- kein `preg_match` mit `testomat` in `AgentRunner.php` + +## Noch in Zielumgebung ausführen + +```bash +php bin/console mto:agent:config:validate +php bin/console mto:agent:regression:test +php bin/console mto:agent:config:audit-source --details +php bin/console mto:agent:config:audit-patterns --details +``` diff --git a/patch_history/RETRIEX_PATCH_P86I_MULTI_PRODUCT_SPLIT_SHOPWARE_REQUESTS_README.md b/patch_history/RETRIEX_PATCH_P86I_MULTI_PRODUCT_SPLIT_SHOPWARE_REQUESTS_README.md new file mode 100644 index 0000000..4ca6115 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_P86I_MULTI_PRODUCT_SPLIT_SHOPWARE_REQUESTS_README.md @@ -0,0 +1,77 @@ +# RetrieX Patch p86i - Multi Product Split Shopware Requests + +## Ziel + +Referenzielle Produktlisten-Follow-ups wie + +```text +gebe mir links zu den produkten aus dem shop +``` + +sollen bei mehreren Produktankern keine kombinierte Pseudo-Query mehr an Shopware senden. + +Vorher konnte aus dem Verlauf eine zusammengeführte Query entstehen, z. B.: + +```text +testomat 2000 cal 808 +``` + +oder als Anzeige/Repair-Query eine Pipe-Liste: + +```text +testomat 2000 self clean | testomat 2000 cal | testomat 808 +``` + +Diese Pipe-/Kombi-Query ist für die Shopware-API keine gültige Einzelproduktsuche. + +## Änderung + +p86i führt einen engen Split-Lookup-Pfad nur für referenzielle Produktlisten-Follow-ups ein: + +1. Produktanker werden aus Verlauf/RAG wie bisher generisch extrahiert. +2. Wenn mindestens zwei Produktanker vorhanden sind, wird keine kombinierte Produktlisten-Query an Shopware gesendet. +3. Stattdessen werden echte separate Shopware-Suchen ausgeführt, z. B.: + +```text +testomat 2000 self clean +testomat 2000 cal +testomat 808 +``` + +4. Ergebnisse werden per Produktidentität gefiltert und dedupliziert zusammengeführt. +5. Die Query-Anzeige nutzt Semikolon-Trennung als reine Anzeige der Einzelabfragen, keine Pipe-Syntax. + +## Scope / Guardrails + +Der Patch ist bewusst eng begrenzt: + +- nur für `isReferentialProductListShopFollowUpPrompt()` +- nur wenn mindestens zwei Produktanker gefunden wurden +- keine Änderung für normale Einzel-Shopqueries +- keine Änderung an Intent-Erkennung, RAG-Retrieval, Ranking, Scoring oder Shop-Matching +- keine feste Produktliste im Core +- keine Sonderlogik für medizinische Geräte +- kein pauschales `gerät => testomat` +- bestehende Identity-Filterung aus p86g/p86h bleibt aktiv + +## Async-Bewertung + +Die getrennten Lookups werden bewusst sequenziell und durch `max_anchors` begrenzt ausgeführt. +Der vorhandene `ShopSearchService::search()` / `StoreApiClient`-Pfad ist synchron aufgebaut. Eine echte parallele Store-API-Ausführung würde einen breiteren Service-/HTTP-Client-Umbau erfordern und wurde bewusst nicht in diesen kleinen Hotfix aufgenommen, um keine unrelated Shop-Flows zu riskieren. + +## Lokale Checks + +```text +php -l src/Agent/AgentRunner.php +find src -name '*.php' -print0 | xargs -0 -n1 php -l +YAML parse OK +``` + +Symfony-Console-Checks müssen in der Zielumgebung mit `vendor/` ausgeführt werden: + +```text +php bin/console mto:agent:config:validate +php bin/console mto:agent:regression:test +php bin/console mto:agent:config:audit-source --details +php bin/console mto:agent:config:audit-patterns --details +``` diff --git a/public/assets/styles/base.css b/public/assets/styles/base.css index 963696f..e7de42f 100644 --- a/public/assets/styles/base.css +++ b/public/assets/styles/base.css @@ -620,6 +620,15 @@ body:not(.retriex-show-detail-cards) #chat .retriex-alert { color: #f8f9fa; } +.retriex-meta-query__list { + display: grid; + gap: 0.25rem; +} + +.retriex-meta-query--multi code { + margin: 0; +} + .retriex-alert { display: flex; gap: 0.75rem; diff --git a/public/index.html b/public/index.html index 1a23797..c8a204a 100644 --- a/public/index.html +++ b/public/index.html @@ -30,7 +30,9 @@
- +
diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index bb626fd..b609745 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -464,13 +464,34 @@ final readonly class AgentRunner ? $shopQueryPreview->searchText : $shopSearchQuery; $shopSearchUsedOptimizedQuery = $optimizedShopQuery !== ''; + $shopSearchDisplayIndividualQueries = []; + $productListSplitLookupQueries = $this->resolveProductListFollowUpSplitLookupQueries( + prompt: $originalPrompt, + userId: $userId, + commerceHistoryContext: $shopQueryHistoryContext, + knowledgeChunks: $knowledgeChunks + ); + + if ($productListSplitLookupQueries !== []) { + $shopSearchDisplayIndividualQueries = $productListSplitLookupQueries; + $shopSearchUsedOptimizedQuery = false; + + $this->agentLogger->info('Prepared product-list follow-up shop search as separate product anchor lookups', [ + 'userId' => $userId, + 'commerceIntent' => $commerceIntent, + 'prompt' => $prompt, + 'shopSearchQuery' => $shopSearchQuery, + 'splitLookupQueries' => $productListSplitLookupQueries, + ]); + } yield $this->systemMsg( $this->buildShopSearchMetaMessage( query: $shopSearchDisplayQuery, commerceIntent: $commerceIntent, usedOptimizedQuery: $shopSearchUsedOptimizedQuery, - originalQuery: $shopSearchQuery + originalQuery: $shopSearchQuery, + individualQueries: $shopSearchDisplayIndividualQueries ), 'meta' ); @@ -481,6 +502,7 @@ final readonly class AgentRunner 'usedOptimizedShopQuery' => $optimizedShopQuery !== '', 'optimizedShopQuery' => $optimizedShopQuery, 'shopSearchQuery' => $shopSearchQuery, + 'individualShopQueries' => $shopSearchDisplayIndividualQueries, 'hasCommerceHistoryContext' => $commerceHistoryContext !== '', 'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext), ]); @@ -503,14 +525,28 @@ final readonly class AgentRunner ); $shopSearchAttempted = true; - $primaryShopResults = $this->searchShop( - $shopSearchQuery, - $commerceIntent, - $userId, - $shopQueryHistoryContext - ); - $primaryShopSearchHadSystemFailure = $this->shopSearchService->hadLastSearchSystemFailure(); - $primaryShopSearchFailureReason = $this->shopSearchService->getLastSearchFailureReason(); + $productListSplitLookupPayload = null; + if ($productListSplitLookupQueries !== []) { + $productListSplitLookupPayload = $this->searchProductListFollowUpSplitLookupQueries( + prompt: $originalPrompt, + userId: $userId, + commerceIntent: $commerceIntent, + commerceHistoryContext: $shopQueryHistoryContext, + queries: $productListSplitLookupQueries + ); + $primaryShopResults = $productListSplitLookupPayload['results']; + $primaryShopSearchHadSystemFailure = $productListSplitLookupPayload['hadSystemFailure']; + $primaryShopSearchFailureReason = $productListSplitLookupPayload['failureReason']; + } else { + $primaryShopResults = $this->searchShop( + $shopSearchQuery, + $commerceIntent, + $userId, + $shopQueryHistoryContext + ); + $primaryShopSearchHadSystemFailure = $this->shopSearchService->hadLastSearchSystemFailure(); + $primaryShopSearchFailureReason = $this->shopSearchService->getLastSearchFailureReason(); + } if ($primaryShopSearchHadSystemFailure) { $this->agentLogger->warning('Shop repair skipped after Store API system failure', [ @@ -532,6 +568,7 @@ final readonly class AgentRunner usedOptimizedQuery: $shopSearchUsedOptimizedQuery, originalQuery: $shopSearchQuery, completed: true, + individualQueries: $shopSearchDisplayIndividualQueries, unavailable: true ), 'meta' @@ -548,27 +585,36 @@ final readonly class AgentRunner 'repairQueries' => [], ]; } else { - yield $this->systemMsg($this->agentRunnerConfig->getShopRepairCheckMessage(), 'think'); + if ($productListSplitLookupPayload !== null) { + $repairPayload = [ + 'results' => $primaryShopResults, + 'attemptedRepair' => true, + 'usedRepair' => $primaryShopResults !== [], + 'repairQueries' => $productListSplitLookupPayload['queries'], + ]; + } else { + yield $this->systemMsg($this->agentRunnerConfig->getShopRepairCheckMessage(), 'think'); - $repairPayload = $this->repairShopResults( - prompt: $prompt, - userId: $userId, - commerceIntent: $commerceIntent, - commerceHistoryContext: $shopQueryHistoryContext, - primaryQuery: $shopSearchQuery, - primaryShopResults: $primaryShopResults, - knowledgeChunks: $knowledgeChunks - ); + $repairPayload = $this->repairShopResults( + prompt: $prompt, + userId: $userId, + commerceIntent: $commerceIntent, + commerceHistoryContext: $shopQueryHistoryContext, + primaryQuery: $shopSearchQuery, + primaryShopResults: $primaryShopResults, + knowledgeChunks: $knowledgeChunks + ); - $repairPayload = $this->repairProductListFollowUpShopResultsWithAnchorLookups( - prompt: $prompt, - userId: $userId, - commerceIntent: $commerceIntent, - commerceHistoryContext: $shopQueryHistoryContext, - shopSearchQuery: $shopSearchQuery, - repairPayload: $repairPayload, - knowledgeChunks: $knowledgeChunks - ); + $repairPayload = $this->repairProductListFollowUpShopResultsWithAnchorLookups( + prompt: $prompt, + userId: $userId, + commerceIntent: $commerceIntent, + commerceHistoryContext: $shopQueryHistoryContext, + shopSearchQuery: $shopSearchQuery, + repairPayload: $repairPayload, + knowledgeChunks: $knowledgeChunks + ); + } } } @@ -588,7 +634,9 @@ final readonly class AgentRunner $shopResults = $directIdentityRepairPayload['results']; } - $shopResults = $this->guardShopResultsByReferencedProductAnchor($shopSearchQuery, $shopResults); + if ($shopSearchDisplayIndividualQueries === []) { + $shopResults = $this->guardShopResultsByReferencedProductAnchor($shopSearchQuery, $shopResults); + } $shopResults = $this->guardShopResultsByExactRequestedAccessoryCode($prompt, $shopSearchQuery, $shopResults); $shopResults = $this->sortShopResultsForLengthRequest($prompt, $shopSearchQuery, $shopResults); $attemptedShopRepair = $repairPayload['attemptedRepair'] || $directIdentityRepairPayload['attemptedRepair']; @@ -598,14 +646,25 @@ final readonly class AgentRunner $directIdentityRepairPayload['repairQueries'] ))); + $completedShopSearchDisplayQuery = $shopSearchDisplayQuery !== '' ? $shopSearchDisplayQuery : $shopSearchQuery; + $completedShopSearchIndividualQueries = $shopSearchDisplayIndividualQueries; + if ( + $usedShopRepair + && $shopRepairQueries !== [] + && $this->isReferentialProductListShopFollowUpPrompt($prompt) + ) { + $completedShopSearchIndividualQueries = $shopRepairQueries; + } + if (!$primaryShopSearchHadSystemFailure) { yield $this->systemMsg( $this->buildShopSearchMetaMessage( - query: $shopSearchDisplayQuery !== '' ? $shopSearchDisplayQuery : $shopSearchQuery, + query: $completedShopSearchDisplayQuery, commerceIntent: $commerceIntent, usedOptimizedQuery: $shopSearchUsedOptimizedQuery, originalQuery: $shopSearchQuery, resultCount: count($shopResults), + individualQueries: $completedShopSearchIndividualQueries, completed: true, attemptedRepair: $attemptedShopRepair, usedRepair: $usedShopRepair @@ -1534,6 +1593,119 @@ final readonly class AgentRunner } } + /** + * Resolve product-list follow-ups into individual product identity lookups + * before calling Shopware. This keeps referential prompts such as "links to + * the products" from sending a combined pseudo-query that Shopware cannot + * interpret as separate products. + * + * @param string[] $knowledgeChunks + * @return string[] + */ + private function resolveProductListFollowUpSplitLookupQueries( + string $prompt, + string $userId, + string $commerceHistoryContext, + array $knowledgeChunks + ): array { + if (!$this->isReferentialProductListShopFollowUpPrompt($prompt)) { + return []; + } + + $anchors = $this->extractProductListFollowUpAnchorsForLookup( + commerceHistoryContext: $commerceHistoryContext, + userId: $userId, + knowledgeChunks: $knowledgeChunks + ); + + if (count($anchors) < 2) { + return []; + } + + return $this->buildProductListFollowUpAnchorLookupQueries($anchors, ''); + } + + /** + * Execute product-list follow-up lookups as actual separate Shopware + * searches. The execution is intentionally sequential and bounded by the + * configured max anchor count. The Store API client currently exposes a + * synchronous search contract; introducing concurrent transport here would + * be a wider service-level change and risk unrelated shop flows. + * + * @param string[] $queries + * @return array{results: array, hadSystemFailure: bool, failureReason: ?string, queries: string[]} + */ + private function searchProductListFollowUpSplitLookupQueries( + string $prompt, + string $userId, + string $commerceIntent, + string $commerceHistoryContext, + array $queries + ): array { + $queries = array_values(array_unique(array_filter(array_map( + static fn(string $query): string => trim($query), + $queries + ), static fn(string $query): bool => $query !== ''))); + + $mergedResults = []; + $seenProducts = []; + $usedQueries = []; + $hadAnySystemFailure = false; + $hadAnySuccessfulSearch = false; + $failureReason = null; + + foreach ($queries as $query) { + $queryResults = $this->searchShop( + $query, + $commerceIntent, + $userId, + $commerceHistoryContext + ); + + if ($this->shopSearchService->hadLastSearchSystemFailure()) { + $hadAnySystemFailure = true; + $failureReason ??= $this->shopSearchService->getLastSearchFailureReason(); + continue; + } + + $hadAnySuccessfulSearch = true; + $usedQueries[] = $query; + + $identityResults = $this->filterProductListFollowUpResultsByAnchorIdentity($queryResults, [$query]); + foreach ($identityResults as $product) { + if (!$product instanceof ShopProductResult) { + continue; + } + + $key = $this->buildShopProductDedupeKey($product); + if (isset($seenProducts[$key])) { + continue; + } + + $seenProducts[$key] = true; + $mergedResults[] = $product; + } + } + + $this->agentLogger->info('Executed product-list follow-up as separate product anchor shop searches', [ + 'userId' => $userId, + 'commerceIntent' => $commerceIntent, + 'prompt' => $prompt, + 'queries' => $queries, + 'usedQueries' => $usedQueries, + 'resultCount' => count($mergedResults), + 'hadAnySystemFailure' => $hadAnySystemFailure, + 'hadAnySuccessfulSearch' => $hadAnySuccessfulSearch, + ]); + + return [ + 'results' => $mergedResults, + 'hadSystemFailure' => $hadAnySystemFailure && !$hadAnySuccessfulSearch, + 'failureReason' => $hadAnySystemFailure && !$hadAnySuccessfulSearch ? $failureReason : null, + 'queries' => $usedQueries !== [] ? $usedQueries : $queries, + ]; + } + /** * Keep referential product-list follow-ups aligned with the concrete product * identities mentioned in the previous context. A combined query containing @@ -1573,9 +1745,14 @@ final readonly class AgentRunner return $repairPayload; } + $identityResults = []; + $coveredAnchorKeys = []; + if ($currentResults !== []) { $identityResults = $this->filterProductListFollowUpResultsByAnchorIdentity($currentResults, $anchors); - if ($identityResults !== []) { + $coveredAnchorKeys = $this->resolveProductListFollowUpCoveredAnchorKeys($identityResults, $anchors); + + if ($identityResults !== [] && count($coveredAnchorKeys) >= count($anchors)) { if (count($identityResults) !== count($currentResults)) { $this->agentLogger->info('Filtered product-list follow-up shop results to referenced product identities', [ 'userId' => $userId, @@ -1599,8 +1776,22 @@ final readonly class AgentRunner } } - $queries = $this->buildProductListFollowUpAnchorLookupQueries($anchors, $shopSearchQuery); + $missingAnchors = $this->filterProductListFollowUpMissingAnchors($anchors, $coveredAnchorKeys); + $lookupAnchors = count($anchors) > 1 && count($coveredAnchorKeys) < count($anchors) + ? $anchors + : ($missingAnchors !== [] ? $missingAnchors : $anchors); + $queries = $this->buildProductListFollowUpAnchorLookupQueries($lookupAnchors, $shopSearchQuery); + if ($queries === []) { + if ($identityResults !== []) { + return [ + 'results' => $identityResults, + 'attemptedRepair' => true, + 'usedRepair' => true, + 'repairQueries' => $repairPayload['repairQueries'] ?? [], + ]; + } + return $currentResults === [] ? $repairPayload : [ 'results' => [], 'attemptedRepair' => true, @@ -1613,6 +1804,20 @@ final readonly class AgentRunner $seenProducts = []; $usedQueries = []; + foreach ($identityResults as $product) { + if (!$product instanceof ShopProductResult) { + continue; + } + + $key = $this->buildShopProductDedupeKey($product); + if (isset($seenProducts[$key])) { + continue; + } + + $seenProducts[$key] = true; + $mergedResults[] = $product; + } + foreach ($queries as $query) { $queryResults = $this->searchShop( $query, @@ -1647,15 +1852,17 @@ final readonly class AgentRunner } } + $repairQueries = array_values(array_unique(array_merge( + $repairPayload['repairQueries'] ?? [], + $queries + ))); + if ($mergedResults === []) { return [ 'results' => [], 'attemptedRepair' => true, 'usedRepair' => false, - 'repairQueries' => array_values(array_unique(array_merge( - $repairPayload['repairQueries'] ?? [], - $queries - ))), + 'repairQueries' => $repairQueries, ]; } @@ -1664,6 +1871,8 @@ final readonly class AgentRunner 'commerceIntent' => $commerceIntent, 'prompt' => $prompt, 'shopSearchQuery' => $shopSearchQuery, + 'anchors' => $anchors, + 'missingAnchors' => $missingAnchors, 'anchorLookupQueries' => $usedQueries, 'resultCount' => count($mergedResults), ]); @@ -1672,10 +1881,7 @@ final readonly class AgentRunner 'results' => $mergedResults, 'attemptedRepair' => true, 'usedRepair' => true, - 'repairQueries' => array_values(array_unique(array_merge( - $repairPayload['repairQueries'] ?? [], - $usedQueries - ))), + 'repairQueries' => $repairQueries, ]; } @@ -1718,21 +1924,15 @@ final readonly class AgentRunner continue; } - // Avoid converting a single already-focused product query into a - // redundant retry. The multi-product case remains eligible because - // not all combined-query tokens belong to each individual anchor. - if (count($anchors) === 1) { - $missing = false; - foreach ($tokens as $token) { - if (!isset($combinedTokens[$token])) { - $missing = true; - break; - } - } - - if (!$missing) { - continue; - } + // Avoid a redundant retry only when the current query already is + // exactly the same focused product query. A combined multi-product + // query may contain all tokens of one anchor, but that still needs + // an individual lookup for the missing product identity. + if ( + count($anchors) === 1 + && $this->buildProductListFollowUpAnchorKey($anchor) === implode(' ', array_keys($combinedTokens)) + ) { + continue; } $queries[] = $anchor; @@ -1741,6 +1941,66 @@ final readonly class AgentRunner return array_values(array_unique($queries)); } + /** + * @param ShopProductResult[] $shopResults + * @param string[] $anchors + * @return array + */ + private function resolveProductListFollowUpCoveredAnchorKeys(array $shopResults, array $anchors): array + { + $covered = []; + $anchors = $this->normalizeProductListFollowUpAnchors($anchors); + + if ($shopResults === [] || $anchors === []) { + return $covered; + } + + foreach ($shopResults as $product) { + if (!$product instanceof ShopProductResult) { + continue; + } + + foreach ($anchors as $anchor) { + if (!$this->shopProductIdentityMatchesProductListAnchor($product, $anchor)) { + continue; + } + + $key = $this->buildProductListFollowUpAnchorKey($anchor); + if ($key !== '') { + $covered[$key] = true; + } + } + } + + return $covered; + } + + /** + * @param string[] $anchors + * @param array $coveredAnchorKeys + * @return string[] + */ + private function filterProductListFollowUpMissingAnchors(array $anchors, array $coveredAnchorKeys): array + { + $missing = []; + + foreach ($this->normalizeProductListFollowUpAnchors($anchors) as $anchor) { + $key = $this->buildProductListFollowUpAnchorKey($anchor); + if ($key === '' || isset($coveredAnchorKeys[$key])) { + continue; + } + + $missing[] = $anchor; + } + + return $missing; + } + + private function buildProductListFollowUpAnchorKey(string $anchor): string + { + return implode(' ', $this->tokenizeShopQueryCandidate($anchor)); + } + /** * @param string[] $anchors * @return string[] @@ -2998,7 +3258,7 @@ final readonly class AgentRunner // A standalone query optimizer may remove words, but it must not add // model numbers or article-like numbers that are absent from the // current user input. Otherwise old context can leak into new shop - // searches, for example "Anschlusskabel pH/Redox" -> "testomat 808". + // searches. if (preg_match('/\d/u', $token) === 1) { return true; } @@ -3765,20 +4025,26 @@ final readonly class AgentRunner private function canonicalizeProductListAnchor(string $anchor): string { + $anchor = $this->trimProductListAnchorToConfiguredStart($anchor); + $tokens = $this->tokenizeShopQueryCandidate($anchor); if ($tokens === []) { return ''; } - if (($tokens[0] ?? '') !== 'testomat') { + $familyTerms = $this->buildShopQueryTokenSet( + $this->agentRunnerConfig->getShopQueryProductListFollowUpCanonicalFamilyTerms() + ); + $familyToken = (string) ($tokens[0] ?? ''); + if ($familyTerms === [] || !isset($familyTerms[$familyToken])) { return trim((string) preg_replace('/\s+/u', ' ', $anchor)); } if (!isset($tokens[1])) { - return 'testomat'; + return $familyToken; } - $canonical = ['testomat', $tokens[1]]; + $canonical = [$familyToken, $tokens[1]]; $variantTerms = $this->buildShopQueryTokenSet( $this->agentRunnerConfig->getShopQueryPositiveTokenFilterAdjacentVariantTerms() ); @@ -3797,6 +4063,36 @@ final readonly class AgentRunner return trim(implode(' ', $canonical)); } + private function trimProductListAnchorToConfiguredStart(string $anchor): string + { + $anchor = trim($anchor); + if ($anchor === '') { + return ''; + } + + foreach ($this->agentRunnerConfig->getShopQueryProductListFollowUpCanonicalStartPatterns() as $pattern) { + if (@preg_match($pattern, $anchor, $match) !== 1) { + continue; + } + + $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 = trim((string) preg_replace('/\s+/u', ' ', $candidate)); + if ($candidate !== '') { + return $candidate; + } + } + + return trim((string) preg_replace('/\s+/u', ' ', $anchor)); + } + private function guardMainDeviceReferentialShopQueryWithHistoryModelAnchor( string $prompt, string $shopSearchQuery, @@ -6903,6 +7199,9 @@ final readonly class AgentRunner return $this->normalizeOneLine($rendered); } + /** + * @param string[] $individualQueries Actual individual Shopware queries that were prepared/executed for split product-list follow-ups. + */ private function buildShopSearchMetaMessage( string $query, string $commerceIntent, @@ -6912,10 +7211,12 @@ final readonly class AgentRunner bool $completed = false, bool $attemptedRepair = false, bool $usedRepair = false, - bool $unavailable = false + bool $unavailable = false, + array $individualQueries = [] ): string { $query = $this->normalizeOneLine($query); $originalQuery = $this->normalizeOneLine($originalQuery); + $individualQueries = $this->normalizeProductListFollowUpIndividualQueriesForDisplay($individualQueries); if ($query === '') { $query = $originalQuery !== '' ? $originalQuery : $this->agentRunnerConfig->getProductionUiText('shop_meta_fallback_query'); @@ -6963,15 +7264,56 @@ final readonly class AgentRunner $html .= '' . htmlspecialchars($repairLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; } - $html .= '' - . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' - . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') - . '
' - . ''; + $html .= ''; + + if ($individualQueries !== []) { + $html .= '
' + . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_queries_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . ''; + + foreach ($individualQueries as $individualQuery) { + $html .= '' . htmlspecialchars($individualQuery, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + } + + $html .= '
'; + } else { + $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '
'; + } + + $html .= ''; return $html; } + /** + * @param string[] $queries + * @return string[] + */ + private function normalizeProductListFollowUpIndividualQueriesForDisplay(array $queries): array + { + $normalized = []; + $seen = []; + + foreach ($queries as $query) { + $query = $this->normalizeOneLine((string) $query); + if ($query === '') { + continue; + } + + $key = mb_strtolower($query, 'UTF-8'); + if (isset($seen[$key])) { + continue; + } + + $seen[$key] = true; + $normalized[] = $query; + } + + return $normalized; + } + private function buildShopUnavailableMessage(?string $reason): string { $reason = $this->normalizeOneLine((string) $reason); diff --git a/src/Config/AgentRunnerConfig.php b/src/Config/AgentRunnerConfig.php index bc131f9..2c9008b 100644 --- a/src/Config/AgentRunnerConfig.php +++ b/src/Config/AgentRunnerConfig.php @@ -1863,6 +1863,22 @@ final class AgentRunnerConfig { return $this->genreStringList('context_resolution.product_list_followup.anchor_patterns'); } + + /** + * @return string[] + */ + public function getShopQueryProductListFollowUpCanonicalStartPatterns(): array + { + return $this->genreStringList('context_resolution.product_list_followup.canonical_start_patterns'); + } + + /** + * @return string[] + */ + public function getShopQueryProductListFollowUpCanonicalFamilyTerms(): array + { + return $this->genreStringList('context_resolution.product_list_followup.canonical_family_terms'); + } public function isShopQueryRagAnchorEnrichmentEnabled(): bool { return $this->getRequiredBool('shop_runtime.context_resolution.rag_anchor_enrichment.enabled'); diff --git a/src/Config/ChatMessagesConfig.php b/src/Config/ChatMessagesConfig.php index d33a278..cf7f716 100644 --- a/src/Config/ChatMessagesConfig.php +++ b/src/Config/ChatMessagesConfig.php @@ -411,6 +411,7 @@ final class ChatMessagesConfig 'agent.production_ui.text.shop_meta_repair_checked', 'agent.production_ui.text.shop_meta_eyebrow', 'agent.production_ui.text.shop_meta_query_label', + 'agent.production_ui.text.shop_meta_queries_label', 'agent.production_ui.text.shop_meta_query_prefix', 'agent.production_ui.text.shop_meta_intent_prefix', 'agent.production_ui.text.shop_unavailable_default_reason', diff --git a/src/Config/RetriexEffectiveConfigProvider.php b/src/Config/RetriexEffectiveConfigProvider.php index 812f4dd..b47bdfd 100644 --- a/src/Config/RetriexEffectiveConfigProvider.php +++ b/src/Config/RetriexEffectiveConfigProvider.php @@ -1370,7 +1370,9 @@ final readonly class RetriexEffectiveConfigProvider $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->validateStringList($this->toList($productListFollowUp['canonical_family_terms'] ?? []), 'genre.configuration_values.context_resolution.product_list_followup.canonical_family_terms', $errors, $warnings); $this->validateRegexPatternList($productListFollowUp['anchor_patterns'] ?? [], 'genre.configuration_values.context_resolution.product_list_followup.anchor_patterns', $errors); + $this->validateRegexPatternList($productListFollowUp['canonical_start_patterns'] ?? [], 'genre.configuration_values.context_resolution.product_list_followup.canonical_start_patterns', $errors); } $shopQueryRuntime = is_array($configurationValues['shop_query_runtime'] ?? null)