From 4832c2e2877ab814cb18d1f7eb0876eb61017877 Mon Sep 17 00:00:00 2001 From: team 1 Date: Wed, 6 May 2026 10:53:54 +0200 Subject: [PATCH] fix p45 --- config/retriex/agent.yaml | 67 +++++++ public/assets/styles/base.css | 4 +- src/Agent/AgentRunner.php | 325 ++++++++++++++++++++++++++++++- src/Config/AgentRunnerConfig.php | 69 +++++++ 4 files changed, 462 insertions(+), 3 deletions(-) diff --git a/config/retriex/agent.yaml b/config/retriex/agent.yaml index ea2a910..d0be2ee 100644 --- a/config/retriex/agent.yaml +++ b/config/retriex/agent.yaml @@ -212,6 +212,13 @@ parameters: vocabulary_maps: synonyms: agent.rag_evidence_guard.synonyms + direct_shop_result_answer: + enabled: true + max_results: 10 + intro: 'Aus den Shopdaten ergeben sich folgende passende Treffer:' + no_results: 'Ich finde in den Shopdaten keine passenden Treffer für die angefragte Produktsuche. Ich liste deshalb keine fachfremden Ersatzprodukte auf.' + sorted_by_length_note: 'Sortierung: aufsteigend nach erkannter Kabellänge.' + no_llm_fallback: max_shop_results: 5 messages: @@ -440,6 +447,66 @@ parameters: comparative_constraint_patterns: - '/\b(?:länger|laenger|kürzer|kuerzer|größer|groesser|kleiner|über|ueber|unter|mindestens|maximal|maximum|minimum|ab|bis|mehr\s+als|weniger\s+als)\s+(?P\d+(?:[,.]\d+)?\s*[\p{L}µ°%]*)\b/iu' + query_stopword_cleanup: + enabled: true + min_query_tokens_after_cleanup: 2 + # Plain Shopware text search should contain product-relevant terms only. + # These terms are UI, instruction, presentation or sorting words and are + # removed after LLM query optimization. Keep this list simple and local. + terms: + - zeige + - zeig + - suche + - such + - finde + - find + - gib + - gebe + - nenne + - mir + - bitte + - ich + - wir + - im + - in + - shop + - für + - fuer + - nach + - mit + - ohne + - von + - zum + - zur + - der + - die + - das + - ein + - eine + - einen + - ordne + - sortiere + - sortiert + - sortierung + - liste + - tabelle + - übersicht + - uebersicht + - auflistung + - meter + - metern + + direct_result_guard: + enabled: true + + length_sort: + enabled: true + trigger_patterns: + - '/\b(?:ordne|sortiere|sortiert|sortierung)\b.{0,80}\b(?:meter|metern|m)\b/iu' + - '/\bnach\s+(?:meter|metern|m)\b/iu' + value_patterns: + - '/(?P\d+(?:[,.]\d+)?)\s*(?:m|meter|metern)\b/iu' + context_usage: referential_terms: - der diff --git a/public/assets/styles/base.css b/public/assets/styles/base.css index 6ece282..18b0799 100644 --- a/public/assets/styles/base.css +++ b/public/assets/styles/base.css @@ -35,7 +35,9 @@ a { color: #7a9ed1; text-decoration: none; } - +li { + margin-bottom: .5rem; +} a:hover { color: #FFF; } diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index d767222..5dd8827 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -467,6 +467,8 @@ final readonly class AgentRunner } $shopResults = $repairPayload['results']; + $shopResults = $this->guardDirectProductShopResults($prompt, $shopSearchQuery, $shopResults); + $shopResults = $this->sortShopResultsForLengthRequest($prompt, $shopSearchQuery, $shopResults); $attemptedShopRepair = $repairPayload['attemptedRepair']; $usedShopRepair = $repairPayload['usedRepair']; $shopRepairQueries = $repairPayload['repairQueries']; @@ -604,7 +606,21 @@ final readonly class AgentRunner knowledgeEvidenceState: $knowledgeEvidenceState ); - $fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer); + $deterministicDirectShopAnswer = $this->buildDeterministicDirectShopResultAnswer( + prompt: $prompt, + shopResults: $shopResults, + commerceIntent: $commerceIntent, + shopSearchAttempted: $shopSearchAttempted, + shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, + shopSearchQuery: $shopSearchQuery + ); + + if ($deterministicDirectShopAnswer !== '') { + $fullOutput = $deterministicDirectShopAnswer; + yield $this->systemMsg($deterministicDirectShopAnswer, 'answer'); + } else { + $fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer); + } yield $this->systemMsg( $this->buildProductionUiMetaMessage( @@ -1565,7 +1581,49 @@ final readonly class AgentRunner ? $this->preserveCurrentInputShopQueryTerms($prompt, $guardedQuery) : $this->preserveCurrentInputShopQueryTerms($prompt, $shopSearchQuery); - return $this->cleanupDirectProductAttributeShopQuery($prompt, $query); + $query = $this->cleanupDirectProductAttributeShopQuery($prompt, $query); + + return $this->cleanupShopQueryStopwords($query); + } + + private function cleanupShopQueryStopwords(string $shopSearchQuery): string + { + $shopSearchQuery = trim($shopSearchQuery); + + if ( + $shopSearchQuery === '' + || !$this->agentRunnerConfig->isShopQueryStopwordCleanupEnabled() + ) { + return $shopSearchQuery; + } + + $removeTokens = []; + foreach ($this->agentRunnerConfig->getShopQueryStopwordCleanupTerms() as $term) { + foreach ($this->tokenizeShopQueryCandidate($term) as $token) { + $removeTokens[$token] = true; + } + } + + if ($removeTokens === []) { + return $shopSearchQuery; + } + + $kept = []; + foreach ($this->tokenizeShopQueryCandidate($shopSearchQuery) as $token) { + if (isset($removeTokens[$token]) || isset($kept[$token])) { + continue; + } + + $kept[$token] = $token; + } + + if (count($kept) < max(1, $this->agentRunnerConfig->getShopQueryStopwordCleanupMinTokens())) { + return $shopSearchQuery; + } + + $cleaned = implode(' ', array_values($kept)); + + return $cleaned !== '' ? $cleaned : $shopSearchQuery; } private function cleanupDirectProductAttributeShopQuery(string $prompt, string $shopSearchQuery): string @@ -2883,6 +2941,269 @@ final readonly class AgentRunner return mb_strtolower($line, 'UTF-8'); } + /** + * @param ShopProductResult[] $shopResults + * @return ShopProductResult[] + */ + private function guardDirectProductShopResults(string $prompt, string $shopSearchQuery, array $shopResults): array + { + if ( + $shopResults === [] + || !$this->agentRunnerConfig->isDirectShopResultGuardEnabled() + ) { + return $shopResults; + } + + $requestedTerms = $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery); + if ($requestedTerms === []) { + return $shopResults; + } + + $filtered = []; + foreach ($shopResults as $product) { + if (!$product instanceof ShopProductResult) { + continue; + } + + if ($this->shopProductMatchesAnyDirectProductTerm($product, $requestedTerms)) { + $filtered[] = $product; + } + } + + return $filtered; + } + + /** + * @param ShopProductResult[] $shopResults + * @return ShopProductResult[] + */ + private function sortShopResultsForLengthRequest(string $prompt, string $shopSearchQuery, array $shopResults): array + { + if ( + count($shopResults) < 2 + || !$this->agentRunnerConfig->isShopResultLengthSortEnabled() + || !$this->isShopResultLengthSortRequested($prompt . ' ' . $shopSearchQuery) + ) { + return $shopResults; + } + + $hasLength = false; + $decorated = []; + + foreach (array_values($shopResults) as $index => $product) { + $length = $product instanceof ShopProductResult + ? $this->extractShopProductLengthMeters($product) + : null; + $hasLength = $hasLength || $length !== null; + $decorated[] = [ + 'index' => $index, + 'length' => $length, + 'product' => $product, + ]; + } + + if (!$hasLength) { + return $shopResults; + } + + usort($decorated, static function (array $a, array $b): int { + if ($a['length'] === null && $b['length'] === null) { + return $a['index'] <=> $b['index']; + } + + if ($a['length'] === null) { + return 1; + } + + if ($b['length'] === null) { + return -1; + } + + $lengthCompare = $a['length'] <=> $b['length']; + + return $lengthCompare !== 0 ? $lengthCompare : ($a['index'] <=> $b['index']); + }); + + return array_values(array_map( + static fn(array $row): mixed => $row['product'], + $decorated + )); + } + + private function isShopResultLengthSortRequested(string $text): bool + { + foreach ($this->agentRunnerConfig->getShopResultLengthSortTriggerPatterns() as $pattern) { + if (@preg_match($pattern, $text) === 1) { + return true; + } + } + + return false; + } + + private function extractShopProductLengthMeters(ShopProductResult $product): ?float + { + $text = trim(implode(' ', array_filter([ + $product->name, + $product->description, + implode(' ', $product->highlights), + $product->customFields, + ]))); + + if ($text === '') { + return null; + } + + foreach ($this->agentRunnerConfig->getShopResultLengthSortValuePatterns() as $pattern) { + if (@preg_match($pattern, $text, $matches) !== 1) { + continue; + } + + $value = $matches['value'] ?? ($matches[1] ?? null); + if (!is_scalar($value)) { + continue; + } + + $normalized = str_replace(',', '.', (string) $value); + if (is_numeric($normalized)) { + return (float) $normalized; + } + } + + return null; + } + + /** + * @return string[] + */ + private function extractRequestedDirectProductTerms(string $prompt, string $shopSearchQuery = ''): array + { + $combined = trim($prompt . ' ' . $shopSearchQuery); + if ($combined === '') { + return []; + } + + $terms = []; + foreach ($this->agentRunnerConfig->getShopQueryProductAttributeCleanupProductTypeTerms() as $term) { + if ($this->containsAllShopQueryTokens($combined, $term)) { + $terms[] = $term; + } + } + + return array_values(array_unique($terms)); + } + + private function containsAllShopQueryTokens(string $text, string $term): bool + { + $tokens = array_fill_keys($this->tokenizeShopQueryCandidate($text), true); + $termTokens = $this->tokenizeShopQueryCandidate($term); + + if ($tokens === [] || $termTokens === []) { + return false; + } + + foreach ($termTokens as $termToken) { + if (!isset($tokens[$termToken])) { + return false; + } + } + + return true; + } + + /** + * @param string[] $requestedTerms + */ + private function shopProductMatchesAnyDirectProductTerm(ShopProductResult $product, array $requestedTerms): bool + { + $productText = trim(implode(' ', array_filter([ + $product->name, + $product->description, + implode(' ', $product->highlights), + $product->customFields, + ]))); + + foreach ($requestedTerms as $term) { + if ($this->containsAllShopQueryTokens($productText, $term)) { + return true; + } + } + + return false; + } + + /** + * @param ShopProductResult[] $shopResults + */ + private function buildDeterministicDirectShopResultAnswer( + string $prompt, + array $shopResults, + string $commerceIntent, + bool $shopSearchAttempted, + bool $shopSearchHadSystemFailure, + string $shopSearchQuery + ): string { + if ( + !$this->agentRunnerConfig->isDirectShopResultAnswerEnabled() + || !$this->isCommerceIntent($commerceIntent) + || !$shopSearchAttempted + || $shopSearchHadSystemFailure + || $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery) === [] + ) { + return ''; + } + + if ($shopResults === []) { + return $this->agentRunnerConfig->getDirectShopResultAnswerNoResultsMessage(); + } + + $lines = [$this->agentRunnerConfig->getDirectShopResultAnswerIntro()]; + + if ($this->isShopResultLengthSortRequested($prompt . ' ' . $shopSearchQuery)) { + $note = trim($this->agentRunnerConfig->getDirectShopResultAnswerSortedByLengthNote()); + if ($note !== '') { + $lines[] = $note; + } + } + + $lines[] = ''; + foreach ($this->buildDirectShopProductLines($shopResults, 'accessory_or_consumable') as $line) { + $lines[] = $line; + } + + return trim(implode("\n", $lines)); + } + + /** + * @param ShopProductResult[] $shopResults + * @return string[] + */ + private function buildDirectShopProductLines(array $shopResults, string $requestedProductRole): array + { + $maxResults = max(1, $this->agentRunnerConfig->getDirectShopResultAnswerMaxResults()); + $lines = []; + $index = 1; + + foreach ($shopResults as $product) { + if (!$product instanceof ShopProductResult) { + continue; + } + + $lines[] = $this->formatNoLlmShopProductLine($product, $index, $requestedProductRole); + $index++; + + if (count($lines) >= $maxResults) { + break; + } + } + + if ($lines === []) { + return [$this->agentRunnerConfig->getNoLlmProductField('unreadable_results_message')]; + } + + return $lines; + } + /** * Build a deterministic safety answer for environments where the LLM returns no tokens. * diff --git a/src/Config/AgentRunnerConfig.php b/src/Config/AgentRunnerConfig.php index 272aa2d..1e97553 100644 --- a/src/Config/AgentRunnerConfig.php +++ b/src/Config/AgentRunnerConfig.php @@ -732,6 +732,31 @@ final class AgentRunnerConfig return $this->getRequiredStringList('final_answer_guard.repeated_line.ignore_patterns'); } + public function isDirectShopResultAnswerEnabled(): bool + { + return $this->getRequiredBool('direct_shop_result_answer.enabled'); + } + + public function getDirectShopResultAnswerMaxResults(): int + { + return $this->getRequiredInt('direct_shop_result_answer.max_results'); + } + + public function getDirectShopResultAnswerIntro(): string + { + return $this->getRequiredString('direct_shop_result_answer.intro'); + } + + public function getDirectShopResultAnswerNoResultsMessage(): string + { + return $this->getRequiredString('direct_shop_result_answer.no_results'); + } + + public function getDirectShopResultAnswerSortedByLengthNote(): string + { + return $this->getRequiredString('direct_shop_result_answer.sorted_by_length_note'); + } + public function getNoLlmFallbackMaxShopResults(): int { return $this->getRequiredInt('no_llm_fallback.max_shop_results'); @@ -1082,6 +1107,50 @@ final class AgentRunnerConfig return $this->getRequiredStringList('shop_prompt.product_attribute_query_cleanup.comparative_constraint_patterns'); } + public function isShopQueryStopwordCleanupEnabled(): bool + { + return $this->getRequiredBool('shop_prompt.query_stopword_cleanup.enabled'); + } + + public function getShopQueryStopwordCleanupMinTokens(): int + { + return $this->getRequiredInt('shop_prompt.query_stopword_cleanup.min_query_tokens_after_cleanup'); + } + + /** + * @return string[] + */ + public function getShopQueryStopwordCleanupTerms(): array + { + return $this->getRequiredStringList('shop_prompt.query_stopword_cleanup.terms'); + } + + public function isDirectShopResultGuardEnabled(): bool + { + return $this->getRequiredBool('shop_prompt.direct_result_guard.enabled'); + } + + public function isShopResultLengthSortEnabled(): bool + { + return $this->getRequiredBool('shop_prompt.length_sort.enabled'); + } + + /** + * @return string[] + */ + public function getShopResultLengthSortTriggerPatterns(): array + { + return $this->getRequiredStringList('shop_prompt.length_sort.trigger_patterns'); + } + + /** + * @return string[] + */ + public function getShopResultLengthSortValuePatterns(): array + { + return $this->getRequiredStringList('shop_prompt.length_sort.value_patterns'); + } + public function getShopPromptIntro(): string { return $this->getRequiredString('shop_prompt.intro');