p86f-g
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user