p69
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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<int, array{label:string, prompt:string}> $actions
|
||||
* @param array<string, bool> $seenActionKeys
|
||||
* @param array<int, array{label:string, prompt:string}> $items
|
||||
* @param ShopProductResult[] $shopResults
|
||||
* @return array{shop_query:string, answer_has_price:bool, role_counts:array<string,int>}
|
||||
*/
|
||||
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) {
|
||||
$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<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(
|
||||
string $query,
|
||||
string $commerceIntent,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
@@ -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<int, array{label:string, prompt:string}>
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getProductionUiFollowUpActions(string $group): array
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
@@ -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 === []) {
|
||||
|
||||
Reference in New Issue
Block a user