diff --git a/config/retriex/agent.yaml b/config/retriex/agent.yaml index 5b226f1..0bbd7fa 100644 --- a/config/retriex/agent.yaml +++ b/config/retriex/agent.yaml @@ -513,6 +513,33 @@ parameters: terms: - puffer - kalibrierpuffer + primary_identity_repair: + enabled: true + min_query_tokens_after_cleanup: 2 + # Only used for a retry query when the direct-result guard would + # otherwise suppress all shop results. Keep product words and context + # such as brand/pH/Redox, but remove target-device wording that can + # push Shopware ranking toward devices instead of the requested + # accessory/consumable. + stop_terms: + - messgerät + - messgeraet + - messgeräte + - messgeraete + - messgeräten + - messgeraeten + - gerät + - geraet + - geräte + - geraete + - geräten + - geraeten + - handmessgerät + - handmessgeraet + - handmessgeräte + - handmessgeraete + - messkoffer + - koffer length_sort: enabled: true diff --git a/patch_history/RETRIEX_PATCH_52_DIRECT_PRODUCT_PRIMARY_IDENTITY_REPAIR_README.md b/patch_history/RETRIEX_PATCH_52_DIRECT_PRODUCT_PRIMARY_IDENTITY_REPAIR_README.md new file mode 100644 index 0000000..a9d41b5 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_52_DIRECT_PRODUCT_PRIMARY_IDENTITY_REPAIR_README.md @@ -0,0 +1,114 @@ +# RetrieX Patch p52 - Direct Product Primary Identity Repair + +## Ziel + +Korrigiert die p51-Regression bei direkten Shop-Produktlisten, bei der echte Store-API-Treffer intern auf `Shop-Treffer: 0` fallen konnten. + +Betroffener Fall: + +```text +welche neomeris puffer gibt es für Kalibrierung von pH-Messgeräten +``` + +Die angezeigte Shopquery bleibt korrekt und soll nicht wieder mit Geräte-/Darstellungslogik vermischt werden: + +```text +neomeris puffer kalibrierung ph messgeräten +``` + +Wenn die Store API mit dieser Suche Treffer liefert, darf RetrieX diese nicht intern durch den direkten Produkt-Guard verlieren. + +## Ursache + +p51 hat den Direct-Product-Guard bewusst verschärft: Bei direkten Produktlisten sollten nur noch Produkte ausgegeben werden, deren primäre Identität, also Produktname/URL, zur angefragten Produktart passt. Das verhindert Geräte/Koffer als Ersatztreffer für Verbrauchsmaterialien. + +Der Guard war aber zu hart, wenn die vorliegende Shop-Trefferliste bereits durch Shopware-Ranking/Repair/Guardrails in Richtung Geräte verschoben war. Dann konnte der Guard alle Treffer entfernen und der Status fiel auf: + +```text +Shop-Treffer: 0 +``` + +obwohl die Store API für die Suchquery echte Produktkandidaten liefert. + +## Änderung + +p52 ergänzt einen kleinen, defensiven Retry nur für diesen Fall: + +1. Die normale Shopquery bleibt unverändert. +2. Der p51-Primary-Identity-Guard läuft weiterhin. +3. Nur wenn der Guard aus einer nicht-leeren Shopliste **alles** entfernt, wird eine bereinigte Repair-Query gebaut. +4. Diese Repair-Query entfernt nur konfigurierte Zielgeräte-Wörter wie `Messgerät`, `Gerät`, `Koffer` und behält Produktart/Brand/Kontext wie `neomeris`, `puffer`, `ph`. +5. Die Repair-Ergebnisse laufen erneut durch denselben Primary-Identity-Guard. +6. Wenn danach weiterhin nichts passt, bleibt die saubere Keine-Treffer-Antwort erhalten; es werden keine Geräte als Ersatz ausgegeben. + +Beispiel Repair-Query: + +```text +neomeris puffer kalibrierung ph +``` + +statt die Anfrage als Geräte-/Koffer-Liste zu behandeln. + +## Wichtig + +- Keine Änderung an der angezeigten primären Shopquery. +- Keine Änderung an Shopware-Kriterien. +- Kein Neomeris-/pH-/Redox-Sonderfall im PHP-Core. +- Die entfernten Zielgeräte-Wörter sind YAML-konfiguriert unter `shop_prompt.direct_result_guard.primary_identity_repair.stop_terms`. +- p51 bleibt erhalten: Geräte/Koffer werden nicht als Pufferliste ausgegeben. +- p48, p45/p46 und der Artikelnummer-Fix p47 bleiben unverändert. + +## Geänderte Dateien + +```text +config/retriex/agent.yaml +src/Agent/AgentRunner.php +src/Config/AgentRunnerConfig.php +patch_history/RETRIEX_PATCH_52_DIRECT_PRODUCT_PRIMARY_IDENTITY_REPAIR_README.md +``` + +## Erwartetes Verhalten + +```text +welche neomeris puffer gibt es für Kalibrierung von pH-Messgeräten +``` + +soll wieder echte Pufferprodukte ausgeben, sofern sie von der Store API geliefert werden, zum Beispiel: + +```text +Neomeris pH-Pufferlösung pH 4.01 +Neomeris pH-Pufferlösung pH 7.00 +Neomeris pH-Pufferlösung pH 9.21 +Neomeris pH-Pufferlösung pH 10.01 +Neomeris Redox-Pufferlösung 200 mV / 475 mV / 650 mV +``` + +Es sollen nicht mehr nur Geräte/Koffer erscheinen, und es soll nicht fälschlich `Shop-Treffer: 0` entstehen, wenn die Store API passende Pufferprodukte liefert. + +## Lokale Checks + +Ausgeführt: + +```bash +php -l src/Agent/AgentRunner.php +php -l src/Config/AgentRunnerConfig.php +python3 - <<'PY' +import yaml +from pathlib import Path +for f in ['config/retriex/agent.yaml', 'config/retriex/vocabulary.yaml']: + yaml.safe_load(Path(f).read_text()) +print('YAML ok') +PY +``` + +`bin/console` wurde lokal nicht ausgeführt, weil `vendor/` im ZIP nicht enthalten ist. + +## Nach dem Einspielen prüfen + +```bash +bin/console cache:clear +bin/console mto:agent:config:validate +bin/console mto:agent:regression:test +bin/console mto:agent:config:audit-source --details +bin/console mto:agent:config:audit-patterns --details +``` diff --git a/public/assets/styles/base.css b/public/assets/styles/base.css index 18b0799..2b37492 100644 --- a/public/assets/styles/base.css +++ b/public/assets/styles/base.css @@ -36,7 +36,7 @@ a { text-decoration: none; } li { - margin-bottom: .5rem; + margin-bottom: .125rem; } a:hover { color: #FFF; diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index 107961e..00080a9 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -486,12 +486,29 @@ final readonly class AgentRunner } } - $shopResults = $repairPayload['results']; - $shopResults = $this->guardDirectProductShopResults($prompt, $shopSearchQuery, $shopResults); + $unguardedShopResults = $repairPayload['results']; + $shopResults = $this->guardDirectProductShopResults($prompt, $shopSearchQuery, $unguardedShopResults); + + $directIdentityRepairPayload = $this->repairEmptyDirectProductPrimaryIdentityResults( + prompt: $prompt, + userId: $userId, + commerceIntent: $commerceIntent, + shopSearchQuery: $shopSearchQuery, + unguardedShopResults: $unguardedShopResults, + guardedShopResults: $shopResults + ); + + if ($directIdentityRepairPayload['results'] !== null) { + $shopResults = $directIdentityRepairPayload['results']; + } + $shopResults = $this->sortShopResultsForLengthRequest($prompt, $shopSearchQuery, $shopResults); - $attemptedShopRepair = $repairPayload['attemptedRepair']; - $usedShopRepair = $repairPayload['usedRepair']; - $shopRepairQueries = $repairPayload['repairQueries']; + $attemptedShopRepair = $repairPayload['attemptedRepair'] || $directIdentityRepairPayload['attemptedRepair']; + $usedShopRepair = $repairPayload['usedRepair'] || $directIdentityRepairPayload['usedRepair']; + $shopRepairQueries = array_values(array_unique(array_merge( + $repairPayload['repairQueries'], + $directIdentityRepairPayload['repairQueries'] + ))); if (!$primaryShopSearchHadSystemFailure) { yield $this->systemMsg( @@ -3081,6 +3098,157 @@ final readonly class AgentRunner return array_values(array_merge($primaryMatches, $corpusMatches)); } + /** + * @param ShopProductResult[] $unguardedShopResults + * @param ShopProductResult[] $guardedShopResults + * @return array{results: array|null, attemptedRepair: bool, usedRepair: bool, repairQueries: string[]} + */ + private function repairEmptyDirectProductPrimaryIdentityResults( + string $prompt, + string $userId, + string $commerceIntent, + string $shopSearchQuery, + array $unguardedShopResults, + array $guardedShopResults + ): array { + $emptyResult = [ + 'results' => null, + 'attemptedRepair' => false, + 'usedRepair' => false, + 'repairQueries' => [], + ]; + + if ( + $guardedShopResults !== [] + || $unguardedShopResults === [] + || !$this->agentRunnerConfig->isDirectShopResultGuardPrimaryIdentityRepairEnabled() + ) { + return $emptyResult; + } + + $requestedTerms = $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery); + if ($requestedTerms === []) { + return $emptyResult; + } + + $repairQuery = $this->buildDirectProductPrimaryIdentityRepairQuery( + shopSearchQuery: $shopSearchQuery, + requestedTerms: $requestedTerms + ); + + if ($repairQuery === '' || $this->normalizeShopQueryForComparison($repairQuery) === $this->normalizeShopQueryForComparison($shopSearchQuery)) { + return $emptyResult; + } + + $this->agentLogger->info('Direct product primary identity guard retrying with cleaned repair query', [ + 'userId' => $userId, + 'commerceIntent' => $commerceIntent, + 'prompt' => $prompt, + 'shopSearchQuery' => $shopSearchQuery, + 'repairQuery' => $repairQuery, + 'unguardedShopResultsCount' => count($unguardedShopResults), + 'requestedTerms' => $requestedTerms, + ]); + + $repairResults = $this->searchShop( + $repairQuery, + $commerceIntent, + $userId, + '' + ); + + if ($this->shopSearchService->hadLastSearchSystemFailure()) { + return [ + 'results' => null, + 'attemptedRepair' => true, + 'usedRepair' => false, + 'repairQueries' => [$repairQuery], + ]; + } + + $guardedRepairResults = $this->guardDirectProductShopResults($prompt, $repairQuery, $repairResults); + + if ($guardedRepairResults === []) { + $this->agentLogger->info('Direct product primary identity repair finished without matching products', [ + 'userId' => $userId, + 'commerceIntent' => $commerceIntent, + 'prompt' => $prompt, + 'shopSearchQuery' => $shopSearchQuery, + 'repairQuery' => $repairQuery, + 'repairResultsCount' => count($repairResults), + ]); + + return [ + 'results' => null, + 'attemptedRepair' => true, + 'usedRepair' => false, + 'repairQueries' => [$repairQuery], + ]; + } + + return [ + 'results' => $guardedRepairResults, + 'attemptedRepair' => true, + 'usedRepair' => true, + 'repairQueries' => [$repairQuery], + ]; + } + + /** + * @param string[] $requestedTerms + */ + private function buildDirectProductPrimaryIdentityRepairQuery(string $shopSearchQuery, array $requestedTerms): string + { + $tokens = $this->tokenizeShopQueryCandidate($shopSearchQuery); + if ($tokens === []) { + return ''; + } + + $stopTokens = []; + foreach ($this->agentRunnerConfig->getDirectShopResultGuardPrimaryIdentityRepairStopTerms() as $term) { + foreach ($this->tokenizeShopQueryCandidate($term) as $token) { + $stopTokens[$token] = true; + } + } + + $requestedTokens = []; + foreach ($requestedTerms as $term) { + foreach ($this->tokenizeShopQueryCandidate($term) as $token) { + $requestedTokens[$token] = true; + } + } + + $kept = []; + foreach ($tokens as $token) { + if (isset($stopTokens[$token]) && !isset($requestedTokens[$token])) { + continue; + } + + if (isset($kept[$token])) { + continue; + } + + $kept[$token] = $token; + } + + foreach (array_keys($requestedTokens) as $requestedToken) { + if (!isset($kept[$requestedToken])) { + $kept[$requestedToken] = $requestedToken; + } + } + + if (count($kept) < max(1, $this->agentRunnerConfig->getDirectShopResultGuardPrimaryIdentityRepairMinQueryTokens())) { + return ''; + } + + return trim(implode(' ', array_values($kept))); + } + + private function normalizeShopQueryForComparison(string $query): string + { + return trim(implode(' ', $this->tokenizeShopQueryCandidate($query))); + } + /** * @param ShopProductResult[] $shopResults * @return ShopProductResult[] diff --git a/src/Config/AgentRunnerConfig.php b/src/Config/AgentRunnerConfig.php index 663657e..df7e294 100644 --- a/src/Config/AgentRunnerConfig.php +++ b/src/Config/AgentRunnerConfig.php @@ -301,6 +301,21 @@ final class AgentRunnerConfig throw new \InvalidArgumentException(sprintf('RetrieX agent config key "%s" must be numeric.', $key)); } + private function getOptionalInt(string $key, int $default): int + { + $value = $this->optionalValue($key); + + if ($value === null) { + return $default; + } + + if (is_numeric($value)) { + return (int) $value; + } + + throw new \InvalidArgumentException(sprintf('RetrieX agent config key "%s" must be numeric.', $key)); + } + private function getRequiredBool(string $key): bool { $value = $this->requiredValue($key); @@ -1158,6 +1173,24 @@ final class AgentRunnerConfig return $this->getOptionalStringList('shop_prompt.direct_result_guard.compound_prefix_match.terms'); } + public function isDirectShopResultGuardPrimaryIdentityRepairEnabled(): bool + { + return $this->getOptionalBool('shop_prompt.direct_result_guard.primary_identity_repair.enabled', true); + } + + public function getDirectShopResultGuardPrimaryIdentityRepairMinQueryTokens(): int + { + return $this->getOptionalInt('shop_prompt.direct_result_guard.primary_identity_repair.min_query_tokens_after_cleanup', 2); + } + + /** + * @return string[] + */ + public function getDirectShopResultGuardPrimaryIdentityRepairStopTerms(): array + { + return $this->getOptionalStringList('shop_prompt.direct_result_guard.primary_identity_repair.stop_terms'); + } + public function isShopResultLengthSortEnabled(): bool { return $this->getRequiredBool('shop_prompt.length_sort.enabled');