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

@@ -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,