From 943c213ac0b281177c2d8ab9f20e8a28fc6709ce Mon Sep 17 00:00:00 2001 From: team 1 Date: Sat, 9 May 2026 20:01:54 +0200 Subject: [PATCH] p69 --- config/retriex/chat-messages.yaml | 14 +- ...9_CONTEXT_AWARE_FOLLOWUP_ACTIONS_README.md | 68 ++++++ public/assets/styles/base.css | 2 +- src/Agent/AgentRunner.php | 198 +++++++++++++++++- src/Config/AgentRunnerConfig.php | 19 +- src/Config/ChatMessagesConfig.php | 17 +- 6 files changed, 296 insertions(+), 22 deletions(-) create mode 100644 patch_history/RETRIEX_PATCH_69_CONTEXT_AWARE_FOLLOWUP_ACTIONS_README.md diff --git a/config/retriex/chat-messages.yaml b/config/retriex/chat-messages.yaml index e9f2b21..ee7b580 100644 --- a/config/retriex/chat-messages.yaml +++ b/config/retriex/chat-messages.yaml @@ -237,14 +237,20 @@ parameters: enabled: true commerce: - label: Im Shop suchen - prompt: Suche im Shop nach den aktuell genannten Produkten oder Messaufgaben. + prompt: Suche im Shop nach {shop_query}. + action_type: shop_search shop_results: - label: Preis anzeigen - prompt: Zeige mir die Preise der aktuell relevanten Produkte. + prompt: Zeige mir die Preise zu {shop_query}. + action_type: price_details - label: Nur Zubehör anzeigen - prompt: Zeige aus der aktuellen Produktauswahl nur Zubehör. + prompt: Suche im Shop nach {shop_query} und zeige daraus nur Zubehör. + action_type: role_filter + target_role: accessory_or_consumable - label: Nur Geräte anzeigen - prompt: Zeige aus der aktuellen Produktauswahl nur Geräte. + prompt: Suche im Shop nach {shop_query} und zeige daraus nur Geräte. + action_type: role_filter + target_role: main_device knowledge: - label: Technische Details anzeigen prompt: Zeige technische Details zur aktuellen Antwort. diff --git a/patch_history/RETRIEX_PATCH_69_CONTEXT_AWARE_FOLLOWUP_ACTIONS_README.md b/patch_history/RETRIEX_PATCH_69_CONTEXT_AWARE_FOLLOWUP_ACTIONS_README.md new file mode 100644 index 0000000..9687251 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_69_CONTEXT_AWARE_FOLLOWUP_ACTIONS_README.md @@ -0,0 +1,68 @@ +# RetrieX Patch p69 - Context-aware Follow-up Actions + +## Ziel + +Dieser Patch härtet die in p67/p68 eingeführten Folgeaktionen: Actions werden nicht mehr nur anhand von `hasShopResults` statisch angezeigt, sondern nach aktuellem Antwort-/Shopkontext gefiltert. + +## Problem + +Bei einer Anfrage wie `testomat 808 indikatoren` wurden nach einer Zubehör-/Indikatorliste trotzdem generische Rollenfilter wie `Nur Geräte anzeigen` angeboten. Beim Klick ging der Produktkontext verloren und die Folgeanfrage konnte zu einer generischen Shopquery wie `geräte` degenerieren. + +## Lösung + +- Follow-up-Actions erhalten optionale Metadaten in `config/retriex/chat-messages.yaml`: + - `action_type: shop_search` + - `action_type: price_details` + - `action_type: role_filter` + - `target_role: accessory_or_consumable|main_device` +- `AgentRunner` baut vor dem Rendern einen Action-Kontext auf: + - aktuelle Shopquery + - ob die finale Antwort bereits Preise enthält + - Rollenverteilung der aktuellen Shop-Treffer +- Rollenfilter werden nur angezeigt, wenn sie wirklich verengen: + - Zielrolle muss in den Ergebnissen vorkommen + - Zielrolle darf nicht bereits die komplette Ergebnismenge sein + - Kontextquery muss vorhanden sein +- Preis-Actions werden ausgeblendet, wenn die Antwort bereits Preise enthält. +- Action-Prompts werden mit `{shop_query}` verankert, damit Klicks nicht mehr zu isolierten Queries wie `geräte` führen. +- Wenn nach der Kontextfilterung keine sinnvolle Action übrig bleibt, wird keine Follow-up-Action-Karte gerendert. + +## Bewusst nicht geändert + +- Kein Eingriff in Retrieval, Scoring, Ranking, PromptBuilder oder Shop-Matching. +- Keine neuen fachlichen Keywordlisten im PHP-Core. +- Produktrollen-Erkennung nutzt bestehende YAML-gepflegte No-LLM-Rollenterms. +- `buildShopProductCardsMessage()` bleibt weiterhin nicht eingehängt. + +## Geänderte Dateien + +- `src/Agent/AgentRunner.php` +- `src/Config/AgentRunnerConfig.php` +- `src/Config/ChatMessagesConfig.php` +- `config/retriex/chat-messages.yaml` + +## Lokale Checks + +- `php -l src/Agent/AgentRunner.php` +- `php -l src/Config/AgentRunnerConfig.php` +- `php -l src/Config/ChatMessagesConfig.php` +- YAML-Parsing von `config/retriex/chat-messages.yaml` +- `ChatMessagesConfig::validate()` +- `AgentRunnerConfig::getProductionUiFollowUpActions('shop_results')` + +## Empfohlene Regressionstests + +1. `testomat 808 indikatoren` + - Erwartung: keine Action `Nur Geräte anzeigen`. + - Erwartung: `Preis anzeigen` nur, wenn die Antwort noch keine Preise enthält. + - Erwartung: keine Action-Karte, falls nur redundante oder nicht sinnvolle Actions übrig wären. + +2. Nach einer gemischten Shop-Trefferliste mit Geräten und Zubehör: + - Erwartung: Rollenfilter erscheinen nur, wenn sie die aktuelle Liste sinnvoll verengen. + - Erwartung: Klick-Prompt enthält den aktuellen Shopkontext, z. B. `Suche im Shop nach und zeige daraus nur Geräte.` + +3. Smalltalk / No-Evidence: + - Erwartung: keine Follow-up-Actions. + +4. Technische RAG-Antwort mit belastbarer Evidenz: + - Erwartung: `Technische Details anzeigen` kann weiterhin erscheinen. diff --git a/public/assets/styles/base.css b/public/assets/styles/base.css index 75707aa..963696f 100644 --- a/public/assets/styles/base.css +++ b/public/assets/styles/base.css @@ -519,7 +519,7 @@ span.think { gap: 0.5rem; margin: 1rem 0 0.25rem; color: rgba(248, 249, 250, 0.76); - font-size: 0.84rem; + font-size: 0.7rem; border-radius: 6px; background-color: transparent !important; border-top: 1px solid #324154; diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index 71be84f..b47c119 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Agent; use App\Commerce\Dto\ShopProductResult; +use App\Commerce\ProductRoleResolver; use App\Commerce\SearchRepairService; use App\Commerce\ShopSearchService; use App\Config\AgentRunnerConfig; @@ -704,7 +705,10 @@ final readonly class AgentRunner isCommerceIntent: $this->isCommerceIntent($commerceIntent), hasShopResults: $shopResults !== [], hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState), - shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure + shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure, + shopResults: $shopResults, + shopSearchQuery: $shopSearchQuery, + answerText: $fullOutput ); if ($followUpActionsMessage !== '') { @@ -4861,27 +4865,54 @@ final readonly class AgentRunner return $this->agentRunnerConfig->getProductionUiTemplate('relevance_default'); } + /** + * @param ShopProductResult[] $shopResults + */ private function buildFollowUpActionsMessage( bool $isCommerceIntent, bool $hasShopResults, bool $hasKnowledge, - bool $shopSearchHadSystemFailure + bool $shopSearchHadSystemFailure, + array $shopResults, + string $shopSearchQuery, + string $answerText ): string { if (!$this->agentRunnerConfig->isProductionUiFollowUpActionsEnabled()) { return ''; } + $context = $this->buildFollowUpActionContext( + shopResults: $shopResults, + shopSearchQuery: $shopSearchQuery, + answerText: $answerText + ); + $actions = []; $seenActionKeys = []; if ($hasShopResults) { - $this->appendFollowUpActions($actions, $seenActionKeys, $this->agentRunnerConfig->getProductionUiFollowUpActions('shop_results')); - } elseif ($isCommerceIntent && !$shopSearchHadSystemFailure) { - $this->appendFollowUpActions($actions, $seenActionKeys, $this->agentRunnerConfig->getProductionUiFollowUpActions('commerce')); + $this->appendFollowUpActions( + actions: $actions, + seenActionKeys: $seenActionKeys, + items: $this->agentRunnerConfig->getProductionUiFollowUpActions('shop_results'), + context: $context + ); + } elseif ($isCommerceIntent && !$shopSearchHadSystemFailure && $context['shop_query'] !== '') { + $this->appendFollowUpActions( + actions: $actions, + seenActionKeys: $seenActionKeys, + items: $this->agentRunnerConfig->getProductionUiFollowUpActions('commerce'), + context: $context + ); } if ($hasKnowledge) { - $this->appendFollowUpActions($actions, $seenActionKeys, $this->agentRunnerConfig->getProductionUiFollowUpActions('knowledge')); + $this->appendFollowUpActions( + actions: $actions, + seenActionKeys: $seenActionKeys, + items: $this->agentRunnerConfig->getProductionUiFollowUpActions('knowledge'), + context: $context + ); } if ($actions === []) { @@ -4907,11 +4938,97 @@ final readonly class AgentRunner } /** - * @param array $actions - * @param array $seenActionKeys - * @param array $items + * @param ShopProductResult[] $shopResults + * @return array{shop_query:string, answer_has_price:bool, role_counts:array} */ - private function appendFollowUpActions(array &$actions, array &$seenActionKeys, array $items): void + private function buildFollowUpActionContext(array $shopResults, string $shopSearchQuery, string $answerText): array + { + $roleCounts = [ + ProductRoleResolver::ROLE_MAIN_DEVICE => 0, + ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE => 0, + ProductRoleResolver::ROLE_AMBIGUOUS_MIXED => 0, + ProductRoleResolver::ROLE_UNKNOWN => 0, + ]; + + foreach ($shopResults as $product) { + if (!$product instanceof ShopProductResult) { + continue; + } + + $role = $this->resolveFollowUpActionShopProductRole($product); + if (!array_key_exists($role, $roleCounts)) { + $role = ProductRoleResolver::ROLE_UNKNOWN; + } + + ++$roleCounts[$role]; + } + + return [ + 'shop_query' => $this->normalizeOneLine($shopSearchQuery), + 'answer_has_price' => $this->followUpActionAnswerAlreadyContainsPrice($answerText), + 'role_counts' => $roleCounts, + ]; + } + + private function resolveFollowUpActionShopProductRole(ShopProductResult $product): string + { + $primaryText = mb_strtolower($this->normalizeOneLine(implode(' ', [ + $product->name, + (string) $product->productNumber, + ])), 'UTF-8'); + + $hasPrimaryAccessory = $this->containsAnyConfiguredTerm($primaryText, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords()); + $hasPrimaryDevice = $this->containsAnyConfiguredTerm($primaryText, $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords()); + + if ($hasPrimaryAccessory && !$hasPrimaryDevice) { + return ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE; + } + + if ($hasPrimaryDevice && !$hasPrimaryAccessory) { + return ProductRoleResolver::ROLE_MAIN_DEVICE; + } + + if ($hasPrimaryAccessory && $hasPrimaryDevice) { + return ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE; + } + + $corpus = mb_strtolower($this->normalizeOneLine(implode(' ', [ + $product->name, + (string) $product->description, + (string) $product->customFields, + implode(' ', $product->highlights), + ])), 'UTF-8'); + + $hasAccessory = $this->containsAnyConfiguredTerm($corpus, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords()); + $hasDevice = $this->containsAnyConfiguredTerm($corpus, $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords()); + + if ($hasAccessory && !$hasDevice) { + return ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE; + } + + if ($hasDevice && !$hasAccessory) { + return ProductRoleResolver::ROLE_MAIN_DEVICE; + } + + if ($hasAccessory && $hasDevice) { + return ProductRoleResolver::ROLE_AMBIGUOUS_MIXED; + } + + return ProductRoleResolver::ROLE_UNKNOWN; + } + + private function followUpActionAnswerAlreadyContainsPrice(string $answerText): bool + { + return preg_match('/(?:\bpreis\b.{0,24}\d+[,.]\d{2}|\d+[,.]\d{2}\s*(?:€|eur)\b|(?:€|eur)\s*\d+[,.]\d{2})/iu', $answerText) === 1; + } + + /** + * @param array> $actions + * @param array $seenActionKeys + * @param array> $items + * @param array{shop_query:string, answer_has_price:bool, role_counts:array} $context + */ + private function appendFollowUpActions(array &$actions, array &$seenActionKeys, array $items, array $context): void { foreach ($items as $item) { $label = trim((string) ($item['label'] ?? '')); @@ -4921,6 +5038,15 @@ final readonly class AgentRunner continue; } + if (!$this->shouldShowFollowUpAction($item, $context)) { + continue; + } + + $actionPrompt = $this->renderFollowUpActionPrompt($actionPrompt, $context); + if ($actionPrompt === '') { + continue; + } + $key = mb_strtolower($label . "\n" . $actionPrompt, 'UTF-8'); if (isset($seenActionKeys[$key])) { continue; @@ -4934,6 +5060,58 @@ final readonly class AgentRunner } } + /** + * @param array $item + * @param array{shop_query:string, answer_has_price:bool, role_counts:array} $context + */ + private function shouldShowFollowUpAction(array $item, array $context): bool + { + $actionType = isset($item['action_type']) && is_scalar($item['action_type']) ? trim((string) $item['action_type']) : ''; + $targetRole = isset($item['target_role']) && is_scalar($item['target_role']) ? trim((string) $item['target_role']) : ''; + + if ($actionType === 'shop_search') { + return $context['shop_query'] !== ''; + } + + if ($actionType === 'price_details') { + return $context['shop_query'] !== '' && !$context['answer_has_price']; + } + + if ($actionType === 'role_filter') { + return $context['shop_query'] !== '' && $this->isFollowUpRoleFilterMeaningful($targetRole, $context['role_counts']); + } + + return true; + } + + /** + * @param array $roleCounts + */ + private function isFollowUpRoleFilterMeaningful(string $targetRole, array $roleCounts): bool + { + if (!isset($roleCounts[$targetRole]) || $roleCounts[$targetRole] <= 0) { + return false; + } + + $totalProducts = array_sum($roleCounts); + if ($totalProducts <= 0) { + return false; + } + + return $roleCounts[$targetRole] < $totalProducts; + } + + /** + * @param array{shop_query:string, answer_has_price:bool, role_counts:array} $context + */ + private function renderFollowUpActionPrompt(string $prompt, array $context): string + { + $shopQuery = $context['shop_query']; + $rendered = str_replace('{shop_query}', $shopQuery, $prompt); + + return $this->normalizeOneLine($rendered); + } + private function buildShopSearchMetaMessage( string $query, string $commerceIntent, diff --git a/src/Config/AgentRunnerConfig.php b/src/Config/AgentRunnerConfig.php index 196d692..564c770 100644 --- a/src/Config/AgentRunnerConfig.php +++ b/src/Config/AgentRunnerConfig.php @@ -410,7 +410,7 @@ final class AgentRunnerConfig } /** - * @return array + * @return array> */ private function getChatActionList(string $chatKey, string $legacyKey): array { @@ -572,7 +572,7 @@ final class AgentRunnerConfig } /** - * @return array + * @return array> */ private function getRequiredActionList(string $key): array { @@ -596,10 +596,21 @@ final class AgentRunnerConfig continue; } - $out[] = [ + $action = [ 'label' => $label, 'prompt' => $prompt, ]; + + foreach (['action_type', 'target_role'] as $optionalKey) { + if (isset($item[$optionalKey]) && is_scalar($item[$optionalKey])) { + $optionalValue = trim((string) $item[$optionalKey]); + if ($optionalValue !== '') { + $action[$optionalKey] = $optionalValue; + } + } + } + + $out[] = $action; } if ($out === []) { @@ -989,7 +1000,7 @@ final class AgentRunnerConfig } /** - * @return array + * @return array> */ public function getProductionUiFollowUpActions(string $group): array { diff --git a/src/Config/ChatMessagesConfig.php b/src/Config/ChatMessagesConfig.php index f650c7d..6159bc5 100644 --- a/src/Config/ChatMessagesConfig.php +++ b/src/Config/ChatMessagesConfig.php @@ -178,7 +178,7 @@ final class ChatMessagesConfig } /** - * @return array + * @return array> */ public function getActionList(string $path): array { @@ -514,7 +514,7 @@ final class ChatMessagesConfig } /** - * @return array + * @return array> */ private function actionList(string $path): array { @@ -538,10 +538,21 @@ final class ChatMessagesConfig continue; } - $out[] = [ + $action = [ 'label' => $label, 'prompt' => $prompt, ]; + + foreach (['action_type', 'target_role'] as $optionalKey) { + if (isset($item[$optionalKey]) && is_scalar($item[$optionalKey])) { + $optionalValue = trim((string) $item[$optionalKey]); + if ($optionalValue !== '') { + $action[$optionalKey] = $optionalValue; + } + } + } + + $out[] = $action; } if ($out === []) {