This commit is contained in:
team 1
2026-05-10 16:34:00 +02:00
parent 6e72dfb2e5
commit 36485027e6
3 changed files with 586 additions and 0 deletions

View File

@@ -559,6 +559,16 @@ final readonly class AgentRunner
primaryShopResults: $primaryShopResults,
knowledgeChunks: $knowledgeChunks
);
$repairPayload = $this->repairProductListFollowUpShopResultsWithAnchorLookups(
prompt: $prompt,
userId: $userId,
commerceIntent: $commerceIntent,
commerceHistoryContext: $shopQueryHistoryContext,
shopSearchQuery: $shopSearchQuery,
repairPayload: $repairPayload,
knowledgeChunks: $knowledgeChunks
);
}
}
@@ -1524,6 +1534,403 @@ final readonly class AgentRunner
}
}
/**
* Keep referential product-list follow-ups aligned with the concrete product
* identities mentioned in the previous context. A combined query containing
* several product anchors can be too strict for Shopware or can return
* accessories whose descriptions merely mention a requested device. In that
* case retry each resolved product anchor separately and keep only identity
* matches (product name / URL), not accessory compatibility matches.
*
* @param array{results: ?array, attemptedRepair: bool, usedRepair: bool, repairQueries: string[]} $repairPayload
* @param string[] $knowledgeChunks
* @return array{results: ?array, attemptedRepair: bool, usedRepair: bool, repairQueries: string[]}
*/
private function repairProductListFollowUpShopResultsWithAnchorLookups(
string $prompt,
string $userId,
string $commerceIntent,
string $commerceHistoryContext,
string $shopSearchQuery,
array $repairPayload,
array $knowledgeChunks
): array {
$currentResults = $repairPayload['results'] ?? [];
if (!is_array($currentResults)) {
$currentResults = [];
}
if (!$this->isReferentialProductListShopFollowUpPrompt($prompt)) {
return $repairPayload;
}
$anchors = $this->extractProductListFollowUpAnchorsForLookup(
commerceHistoryContext: $commerceHistoryContext,
userId: $userId,
knowledgeChunks: $knowledgeChunks
);
if ($anchors === []) {
return $repairPayload;
}
if ($currentResults !== []) {
$identityResults = $this->filterProductListFollowUpResultsByAnchorIdentity($currentResults, $anchors);
if ($identityResults !== []) {
if (count($identityResults) !== count($currentResults)) {
$this->agentLogger->info('Filtered product-list follow-up shop results to referenced product identities', [
'userId' => $userId,
'commerceIntent' => $commerceIntent,
'prompt' => $prompt,
'shopSearchQuery' => $shopSearchQuery,
'anchors' => $anchors,
'originalResultCount' => count($currentResults),
'filteredResultCount' => count($identityResults),
]);
return [
'results' => $identityResults,
'attemptedRepair' => true,
'usedRepair' => true,
'repairQueries' => $repairPayload['repairQueries'] ?? [],
];
}
return $repairPayload;
}
}
$queries = $this->buildProductListFollowUpAnchorLookupQueries($anchors, $shopSearchQuery);
if ($queries === []) {
return $currentResults === [] ? $repairPayload : [
'results' => [],
'attemptedRepair' => true,
'usedRepair' => false,
'repairQueries' => $repairPayload['repairQueries'] ?? [],
];
}
$mergedResults = [];
$seenProducts = [];
$usedQueries = [];
foreach ($queries as $query) {
$queryResults = $this->searchShop(
$query,
$commerceIntent,
$userId,
$commerceHistoryContext
);
if ($this->shopSearchService->hadLastSearchSystemFailure()) {
continue;
}
$queryResults = $this->filterProductListFollowUpResultsByAnchorIdentity($queryResults, [$query]);
if ($queryResults === []) {
continue;
}
$usedQueries[] = $query;
foreach ($queryResults as $product) {
if (!$product instanceof ShopProductResult) {
continue;
}
$key = $this->buildShopProductDedupeKey($product);
if (isset($seenProducts[$key])) {
continue;
}
$seenProducts[$key] = true;
$mergedResults[] = $product;
}
}
if ($mergedResults === []) {
return [
'results' => [],
'attemptedRepair' => true,
'usedRepair' => false,
'repairQueries' => array_values(array_unique(array_merge(
$repairPayload['repairQueries'] ?? [],
$queries
))),
];
}
$this->agentLogger->info('Repaired product-list follow-up shop search with separate product identity anchor lookups', [
'userId' => $userId,
'commerceIntent' => $commerceIntent,
'prompt' => $prompt,
'shopSearchQuery' => $shopSearchQuery,
'anchorLookupQueries' => $usedQueries,
'resultCount' => count($mergedResults),
]);
return [
'results' => $mergedResults,
'attemptedRepair' => true,
'usedRepair' => true,
'repairQueries' => array_values(array_unique(array_merge(
$repairPayload['repairQueries'] ?? [],
$usedQueries
))),
];
}
/**
* @param string[] $knowledgeChunks
* @return string[]
*/
private function extractProductListFollowUpAnchorsForLookup(
string $commerceHistoryContext,
string $userId,
array $knowledgeChunks
): array {
$anchors = [];
foreach ($this->buildProductListFollowUpAnchorContextCandidates($commerceHistoryContext, $userId) as $contextCandidate) {
$anchors = $this->extractLatestHistoryProductListAnchors($contextCandidate);
if ($anchors !== []) {
break;
}
}
if ($anchors === []) {
$anchors = $this->extractProductListAnchorsFromKnowledgeChunks($knowledgeChunks);
}
return $this->normalizeProductListFollowUpAnchors($anchors);
}
/**
* @param string[] $anchors
* @return string[]
*/
private function buildProductListFollowUpAnchorLookupQueries(array $anchors, string $shopSearchQuery): array
{
$queries = [];
$combinedTokens = array_fill_keys($this->tokenizeShopQueryCandidate($shopSearchQuery), true);
foreach ($this->normalizeProductListFollowUpAnchors($anchors) as $anchor) {
$tokens = $this->tokenizeShopQueryCandidate($anchor);
if ($tokens === []) {
continue;
}
// Avoid converting a single already-focused product query into a
// redundant retry. The multi-product case remains eligible because
// not all combined-query tokens belong to each individual anchor.
if (count($anchors) === 1) {
$missing = false;
foreach ($tokens as $token) {
if (!isset($combinedTokens[$token])) {
$missing = true;
break;
}
}
if (!$missing) {
continue;
}
}
$queries[] = $anchor;
}
return array_values(array_unique($queries));
}
/**
* @param string[] $anchors
* @return string[]
*/
private function normalizeProductListFollowUpAnchors(array $anchors): array
{
$maxAnchors = max(1, $this->agentRunnerConfig->getShopQueryProductListFollowUpMaxAnchors());
$normalized = [];
$seen = [];
foreach ($anchors as $anchor) {
$anchor = $this->canonicalizeProductListAnchor($this->normalizeShopQueryAnchor((string) $anchor));
if ($anchor === '') {
continue;
}
$key = implode(' ', $this->tokenizeShopQueryCandidate($anchor));
if ($key === '' || isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$normalized[] = $anchor;
if (count($normalized) >= $maxAnchors) {
break;
}
}
return $normalized;
}
/**
* @param ShopProductResult[] $shopResults
* @param string[] $anchors
* @return ShopProductResult[]
*/
private function filterProductListFollowUpResultsByAnchorIdentity(array $shopResults, array $anchors): array
{
$anchors = $this->normalizeProductListFollowUpAnchors($anchors);
if ($shopResults === [] || $anchors === []) {
return [];
}
$filtered = [];
$seenProducts = [];
foreach ($shopResults as $product) {
if (!$product instanceof ShopProductResult) {
continue;
}
foreach ($anchors as $anchor) {
if (!$this->shopProductIdentityMatchesProductListAnchor($product, $anchor)) {
continue;
}
$key = $this->buildShopProductDedupeKey($product);
if (!isset($seenProducts[$key])) {
$seenProducts[$key] = true;
$filtered[] = $product;
}
break;
}
}
return $filtered;
}
private function shopProductIdentityMatchesProductListAnchor(ShopProductResult $product, string $anchor): bool
{
$anchorTokens = $this->tokenizeShopQueryCandidate($anchor);
if ($anchorTokens === []) {
return false;
}
$nameTokens = $this->tokenizeShopQueryCandidate($product->name);
if (
($this->tokenSequenceStartsWith($nameTokens, $anchorTokens)
|| $this->tokenSequenceStartsWithAfterQuantityPrefix($nameTokens, $anchorTokens))
&& !$this->productListIdentityContainsAccessoryRoleOutsideAnchor($product->name, $anchor)
) {
return true;
}
$urlPath = is_string($product->url) ? (string) (parse_url($product->url, PHP_URL_PATH) ?? '') : '';
$urlSegments = array_values(array_filter(
explode('/', trim($urlPath, '/')),
static fn(string $segment): bool => $segment !== ''
));
$slug = $urlSegments !== [] ? (string) $urlSegments[0] : '';
$slugTokens = $slug !== '' ? $this->tokenizeShopQueryCandidate($slug) : [];
if (
($this->tokenSequenceStartsWith($slugTokens, $anchorTokens)
|| $this->tokenSequenceStartsWithAfterQuantityPrefix($slugTokens, $anchorTokens))
&& !$this->productListIdentityContainsAccessoryRoleOutsideAnchor($slug, $anchor)
) {
return true;
}
return false;
}
private function productListIdentityContainsAccessoryRoleOutsideAnchor(string $identityText, string $anchor): bool
{
$identityTokens = array_fill_keys($this->tokenizeShopQueryCandidate($identityText), true);
if ($identityTokens === []) {
return false;
}
$anchorTokens = array_fill_keys($this->tokenizeShopQueryCandidate($anchor), true);
$accessoryTokens = $this->buildShopQueryTokenSet(
$this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords()
);
foreach (array_keys($accessoryTokens) as $accessoryToken) {
if (isset($anchorTokens[$accessoryToken])) {
continue;
}
if (isset($identityTokens[$accessoryToken])) {
return true;
}
}
return false;
}
/**
* @param string[] $tokens
* @param string[] $prefix
*/
private function tokenSequenceStartsWith(array $tokens, array $prefix): bool
{
if ($tokens === [] || $prefix === [] || count($tokens) < count($prefix)) {
return false;
}
foreach ($prefix as $index => $token) {
if (($tokens[$index] ?? null) !== $token) {
return false;
}
}
return true;
}
/**
* @param string[] $tokens
* @param string[] $prefix
*/
private function tokenSequenceStartsWithAfterQuantityPrefix(array $tokens, array $prefix): bool
{
if (count($tokens) < count($prefix) + 1) {
return false;
}
$firstToken = (string) ($tokens[0] ?? '');
if (preg_match('/^\d+x$/iu', $firstToken) === 1) {
return $this->tokenSequenceStartsWith(array_slice($tokens, 1), $prefix);
}
if (preg_match('/^\d+$/u', $firstToken) === 1 && (($tokens[1] ?? null) === 'x')) {
return $this->tokenSequenceStartsWith(array_slice($tokens, 2), $prefix);
}
return false;
}
private function buildShopProductDedupeKey(ShopProductResult $product): string
{
$productNumber = trim((string) $product->productNumber);
if ($productNumber !== '') {
return 'number:' . mb_strtolower($productNumber, 'UTF-8');
}
$id = trim($product->id);
if ($id !== '') {
return 'id:' . mb_strtolower($id, 'UTF-8');
}
$url = trim((string) $product->url);
if ($url !== '') {
return 'url:' . mb_strtolower($url, 'UTF-8');
}
return 'name:' . mb_strtolower(trim($product->name), 'UTF-8');
}
private function resolveShopQueryHistoryContext(string $prompt, string $commerceHistoryContext): string
{
$commerceHistoryContext = trim($commerceHistoryContext);