This commit is contained in:
team 1
2026-05-09 20:01:54 +02:00
parent 00a1bdecf9
commit 943c213ac0
6 changed files with 296 additions and 22 deletions

View File

@@ -237,14 +237,20 @@ parameters:
enabled: true enabled: true
commerce: commerce:
- label: Im Shop suchen - 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: shop_results:
- label: Preis anzeigen - 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 - 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 - 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: knowledge:
- label: Technische Details anzeigen - label: Technische Details anzeigen
prompt: Zeige technische Details zur aktuellen Antwort. prompt: Zeige technische Details zur aktuellen Antwort.

View File

@@ -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 <shop_query> 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.

View File

@@ -519,7 +519,7 @@ span.think {
gap: 0.5rem; gap: 0.5rem;
margin: 1rem 0 0.25rem; margin: 1rem 0 0.25rem;
color: rgba(248, 249, 250, 0.76); color: rgba(248, 249, 250, 0.76);
font-size: 0.84rem; font-size: 0.7rem;
border-radius: 6px; border-radius: 6px;
background-color: transparent !important; background-color: transparent !important;
border-top: 1px solid #324154; border-top: 1px solid #324154;

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Agent; namespace App\Agent;
use App\Commerce\Dto\ShopProductResult; use App\Commerce\Dto\ShopProductResult;
use App\Commerce\ProductRoleResolver;
use App\Commerce\SearchRepairService; use App\Commerce\SearchRepairService;
use App\Commerce\ShopSearchService; use App\Commerce\ShopSearchService;
use App\Config\AgentRunnerConfig; use App\Config\AgentRunnerConfig;
@@ -704,7 +705,10 @@ final readonly class AgentRunner
isCommerceIntent: $this->isCommerceIntent($commerceIntent), isCommerceIntent: $this->isCommerceIntent($commerceIntent),
hasShopResults: $shopResults !== [], hasShopResults: $shopResults !== [],
hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState), hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState),
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
shopResults: $shopResults,
shopSearchQuery: $shopSearchQuery,
answerText: $fullOutput
); );
if ($followUpActionsMessage !== '') { if ($followUpActionsMessage !== '') {
@@ -4861,27 +4865,54 @@ final readonly class AgentRunner
return $this->agentRunnerConfig->getProductionUiTemplate('relevance_default'); return $this->agentRunnerConfig->getProductionUiTemplate('relevance_default');
} }
/**
* @param ShopProductResult[] $shopResults
*/
private function buildFollowUpActionsMessage( private function buildFollowUpActionsMessage(
bool $isCommerceIntent, bool $isCommerceIntent,
bool $hasShopResults, bool $hasShopResults,
bool $hasKnowledge, bool $hasKnowledge,
bool $shopSearchHadSystemFailure bool $shopSearchHadSystemFailure,
array $shopResults,
string $shopSearchQuery,
string $answerText
): string { ): string {
if (!$this->agentRunnerConfig->isProductionUiFollowUpActionsEnabled()) { if (!$this->agentRunnerConfig->isProductionUiFollowUpActionsEnabled()) {
return ''; return '';
} }
$context = $this->buildFollowUpActionContext(
shopResults: $shopResults,
shopSearchQuery: $shopSearchQuery,
answerText: $answerText
);
$actions = []; $actions = [];
$seenActionKeys = []; $seenActionKeys = [];
if ($hasShopResults) { if ($hasShopResults) {
$this->appendFollowUpActions($actions, $seenActionKeys, $this->agentRunnerConfig->getProductionUiFollowUpActions('shop_results')); $this->appendFollowUpActions(
} elseif ($isCommerceIntent && !$shopSearchHadSystemFailure) { actions: $actions,
$this->appendFollowUpActions($actions, $seenActionKeys, $this->agentRunnerConfig->getProductionUiFollowUpActions('commerce')); 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) { 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 === []) { if ($actions === []) {
@@ -4907,11 +4938,97 @@ final readonly class AgentRunner
} }
/** /**
* @param array<int, array{label:string, prompt:string}> $actions * @param ShopProductResult[] $shopResults
* @param array<string, bool> $seenActionKeys * @return array{shop_query:string, answer_has_price:bool, role_counts:array<string,int>}
* @param array<int, array{label:string, prompt:string}> $items
*/ */
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<int, array<string, mixed>> $actions
* @param array<string, bool> $seenActionKeys
* @param array<int, array<string, mixed>> $items
* @param array{shop_query:string, answer_has_price:bool, role_counts:array<string,int>} $context
*/
private function appendFollowUpActions(array &$actions, array &$seenActionKeys, array $items, array $context): void
{ {
foreach ($items as $item) { foreach ($items as $item) {
$label = trim((string) ($item['label'] ?? '')); $label = trim((string) ($item['label'] ?? ''));
@@ -4921,6 +5038,15 @@ final readonly class AgentRunner
continue; continue;
} }
if (!$this->shouldShowFollowUpAction($item, $context)) {
continue;
}
$actionPrompt = $this->renderFollowUpActionPrompt($actionPrompt, $context);
if ($actionPrompt === '') {
continue;
}
$key = mb_strtolower($label . "\n" . $actionPrompt, 'UTF-8'); $key = mb_strtolower($label . "\n" . $actionPrompt, 'UTF-8');
if (isset($seenActionKeys[$key])) { if (isset($seenActionKeys[$key])) {
continue; continue;
@@ -4934,6 +5060,58 @@ final readonly class AgentRunner
} }
} }
/**
* @param array<string, mixed> $item
* @param array{shop_query:string, answer_has_price:bool, role_counts:array<string,int>} $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<string, int> $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<string,int>} $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( private function buildShopSearchMetaMessage(
string $query, string $query,
string $commerceIntent, string $commerceIntent,

View File

@@ -410,7 +410,7 @@ final class AgentRunnerConfig
} }
/** /**
* @return array<int, array{label:string, prompt:string}> * @return array<int, array<string, mixed>>
*/ */
private function getChatActionList(string $chatKey, string $legacyKey): array private function getChatActionList(string $chatKey, string $legacyKey): array
{ {
@@ -572,7 +572,7 @@ final class AgentRunnerConfig
} }
/** /**
* @return array<int, array{label:string, prompt:string}> * @return array<int, array<string, mixed>>
*/ */
private function getRequiredActionList(string $key): array private function getRequiredActionList(string $key): array
{ {
@@ -596,10 +596,21 @@ final class AgentRunnerConfig
continue; continue;
} }
$out[] = [ $action = [
'label' => $label, 'label' => $label,
'prompt' => $prompt, '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 === []) { if ($out === []) {
@@ -989,7 +1000,7 @@ final class AgentRunnerConfig
} }
/** /**
* @return array<int, array{label:string, prompt:string}> * @return array<int, array<string, mixed>>
*/ */
public function getProductionUiFollowUpActions(string $group): array public function getProductionUiFollowUpActions(string $group): array
{ {

View File

@@ -178,7 +178,7 @@ final class ChatMessagesConfig
} }
/** /**
* @return array<int, array{label:string, prompt:string}> * @return array<int, array<string, mixed>>
*/ */
public function getActionList(string $path): array public function getActionList(string $path): array
{ {
@@ -514,7 +514,7 @@ final class ChatMessagesConfig
} }
/** /**
* @return array<int, array{label:string, prompt:string}> * @return array<int, array<string, mixed>>
*/ */
private function actionList(string $path): array private function actionList(string $path): array
{ {
@@ -538,10 +538,21 @@ final class ChatMessagesConfig
continue; continue;
} }
$out[] = [ $action = [
'label' => $label, 'label' => $label,
'prompt' => $prompt, '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 === []) { if ($out === []) {