|
|
|
|
@@ -464,13 +464,34 @@ final readonly class AgentRunner
|
|
|
|
|
? $shopQueryPreview->searchText
|
|
|
|
|
: $shopSearchQuery;
|
|
|
|
|
$shopSearchUsedOptimizedQuery = $optimizedShopQuery !== '';
|
|
|
|
|
$shopSearchDisplayIndividualQueries = [];
|
|
|
|
|
$productListSplitLookupQueries = $this->resolveProductListFollowUpSplitLookupQueries(
|
|
|
|
|
prompt: $originalPrompt,
|
|
|
|
|
userId: $userId,
|
|
|
|
|
commerceHistoryContext: $shopQueryHistoryContext,
|
|
|
|
|
knowledgeChunks: $knowledgeChunks
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if ($productListSplitLookupQueries !== []) {
|
|
|
|
|
$shopSearchDisplayIndividualQueries = $productListSplitLookupQueries;
|
|
|
|
|
$shopSearchUsedOptimizedQuery = false;
|
|
|
|
|
|
|
|
|
|
$this->agentLogger->info('Prepared product-list follow-up shop search as separate product anchor lookups', [
|
|
|
|
|
'userId' => $userId,
|
|
|
|
|
'commerceIntent' => $commerceIntent,
|
|
|
|
|
'prompt' => $prompt,
|
|
|
|
|
'shopSearchQuery' => $shopSearchQuery,
|
|
|
|
|
'splitLookupQueries' => $productListSplitLookupQueries,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
yield $this->systemMsg(
|
|
|
|
|
$this->buildShopSearchMetaMessage(
|
|
|
|
|
query: $shopSearchDisplayQuery,
|
|
|
|
|
commerceIntent: $commerceIntent,
|
|
|
|
|
usedOptimizedQuery: $shopSearchUsedOptimizedQuery,
|
|
|
|
|
originalQuery: $shopSearchQuery
|
|
|
|
|
originalQuery: $shopSearchQuery,
|
|
|
|
|
individualQueries: $shopSearchDisplayIndividualQueries
|
|
|
|
|
),
|
|
|
|
|
'meta'
|
|
|
|
|
);
|
|
|
|
|
@@ -481,6 +502,7 @@ final readonly class AgentRunner
|
|
|
|
|
'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
|
|
|
|
|
'optimizedShopQuery' => $optimizedShopQuery,
|
|
|
|
|
'shopSearchQuery' => $shopSearchQuery,
|
|
|
|
|
'individualShopQueries' => $shopSearchDisplayIndividualQueries,
|
|
|
|
|
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
|
|
|
|
|
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
|
|
|
|
]);
|
|
|
|
|
@@ -503,14 +525,28 @@ final readonly class AgentRunner
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$shopSearchAttempted = true;
|
|
|
|
|
$primaryShopResults = $this->searchShop(
|
|
|
|
|
$shopSearchQuery,
|
|
|
|
|
$commerceIntent,
|
|
|
|
|
$userId,
|
|
|
|
|
$shopQueryHistoryContext
|
|
|
|
|
);
|
|
|
|
|
$primaryShopSearchHadSystemFailure = $this->shopSearchService->hadLastSearchSystemFailure();
|
|
|
|
|
$primaryShopSearchFailureReason = $this->shopSearchService->getLastSearchFailureReason();
|
|
|
|
|
$productListSplitLookupPayload = null;
|
|
|
|
|
if ($productListSplitLookupQueries !== []) {
|
|
|
|
|
$productListSplitLookupPayload = $this->searchProductListFollowUpSplitLookupQueries(
|
|
|
|
|
prompt: $originalPrompt,
|
|
|
|
|
userId: $userId,
|
|
|
|
|
commerceIntent: $commerceIntent,
|
|
|
|
|
commerceHistoryContext: $shopQueryHistoryContext,
|
|
|
|
|
queries: $productListSplitLookupQueries
|
|
|
|
|
);
|
|
|
|
|
$primaryShopResults = $productListSplitLookupPayload['results'];
|
|
|
|
|
$primaryShopSearchHadSystemFailure = $productListSplitLookupPayload['hadSystemFailure'];
|
|
|
|
|
$primaryShopSearchFailureReason = $productListSplitLookupPayload['failureReason'];
|
|
|
|
|
} else {
|
|
|
|
|
$primaryShopResults = $this->searchShop(
|
|
|
|
|
$shopSearchQuery,
|
|
|
|
|
$commerceIntent,
|
|
|
|
|
$userId,
|
|
|
|
|
$shopQueryHistoryContext
|
|
|
|
|
);
|
|
|
|
|
$primaryShopSearchHadSystemFailure = $this->shopSearchService->hadLastSearchSystemFailure();
|
|
|
|
|
$primaryShopSearchFailureReason = $this->shopSearchService->getLastSearchFailureReason();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($primaryShopSearchHadSystemFailure) {
|
|
|
|
|
$this->agentLogger->warning('Shop repair skipped after Store API system failure', [
|
|
|
|
|
@@ -532,6 +568,7 @@ final readonly class AgentRunner
|
|
|
|
|
usedOptimizedQuery: $shopSearchUsedOptimizedQuery,
|
|
|
|
|
originalQuery: $shopSearchQuery,
|
|
|
|
|
completed: true,
|
|
|
|
|
individualQueries: $shopSearchDisplayIndividualQueries,
|
|
|
|
|
unavailable: true
|
|
|
|
|
),
|
|
|
|
|
'meta'
|
|
|
|
|
@@ -548,27 +585,36 @@ final readonly class AgentRunner
|
|
|
|
|
'repairQueries' => [],
|
|
|
|
|
];
|
|
|
|
|
} else {
|
|
|
|
|
yield $this->systemMsg($this->agentRunnerConfig->getShopRepairCheckMessage(), 'think');
|
|
|
|
|
if ($productListSplitLookupPayload !== null) {
|
|
|
|
|
$repairPayload = [
|
|
|
|
|
'results' => $primaryShopResults,
|
|
|
|
|
'attemptedRepair' => true,
|
|
|
|
|
'usedRepair' => $primaryShopResults !== [],
|
|
|
|
|
'repairQueries' => $productListSplitLookupPayload['queries'],
|
|
|
|
|
];
|
|
|
|
|
} else {
|
|
|
|
|
yield $this->systemMsg($this->agentRunnerConfig->getShopRepairCheckMessage(), 'think');
|
|
|
|
|
|
|
|
|
|
$repairPayload = $this->repairShopResults(
|
|
|
|
|
prompt: $prompt,
|
|
|
|
|
userId: $userId,
|
|
|
|
|
commerceIntent: $commerceIntent,
|
|
|
|
|
commerceHistoryContext: $shopQueryHistoryContext,
|
|
|
|
|
primaryQuery: $shopSearchQuery,
|
|
|
|
|
primaryShopResults: $primaryShopResults,
|
|
|
|
|
knowledgeChunks: $knowledgeChunks
|
|
|
|
|
);
|
|
|
|
|
$repairPayload = $this->repairShopResults(
|
|
|
|
|
prompt: $prompt,
|
|
|
|
|
userId: $userId,
|
|
|
|
|
commerceIntent: $commerceIntent,
|
|
|
|
|
commerceHistoryContext: $shopQueryHistoryContext,
|
|
|
|
|
primaryQuery: $shopSearchQuery,
|
|
|
|
|
primaryShopResults: $primaryShopResults,
|
|
|
|
|
knowledgeChunks: $knowledgeChunks
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$repairPayload = $this->repairProductListFollowUpShopResultsWithAnchorLookups(
|
|
|
|
|
prompt: $prompt,
|
|
|
|
|
userId: $userId,
|
|
|
|
|
commerceIntent: $commerceIntent,
|
|
|
|
|
commerceHistoryContext: $shopQueryHistoryContext,
|
|
|
|
|
shopSearchQuery: $shopSearchQuery,
|
|
|
|
|
repairPayload: $repairPayload,
|
|
|
|
|
knowledgeChunks: $knowledgeChunks
|
|
|
|
|
);
|
|
|
|
|
$repairPayload = $this->repairProductListFollowUpShopResultsWithAnchorLookups(
|
|
|
|
|
prompt: $prompt,
|
|
|
|
|
userId: $userId,
|
|
|
|
|
commerceIntent: $commerceIntent,
|
|
|
|
|
commerceHistoryContext: $shopQueryHistoryContext,
|
|
|
|
|
shopSearchQuery: $shopSearchQuery,
|
|
|
|
|
repairPayload: $repairPayload,
|
|
|
|
|
knowledgeChunks: $knowledgeChunks
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -588,7 +634,9 @@ final readonly class AgentRunner
|
|
|
|
|
$shopResults = $directIdentityRepairPayload['results'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$shopResults = $this->guardShopResultsByReferencedProductAnchor($shopSearchQuery, $shopResults);
|
|
|
|
|
if ($shopSearchDisplayIndividualQueries === []) {
|
|
|
|
|
$shopResults = $this->guardShopResultsByReferencedProductAnchor($shopSearchQuery, $shopResults);
|
|
|
|
|
}
|
|
|
|
|
$shopResults = $this->guardShopResultsByExactRequestedAccessoryCode($prompt, $shopSearchQuery, $shopResults);
|
|
|
|
|
$shopResults = $this->sortShopResultsForLengthRequest($prompt, $shopSearchQuery, $shopResults);
|
|
|
|
|
$attemptedShopRepair = $repairPayload['attemptedRepair'] || $directIdentityRepairPayload['attemptedRepair'];
|
|
|
|
|
@@ -598,14 +646,25 @@ final readonly class AgentRunner
|
|
|
|
|
$directIdentityRepairPayload['repairQueries']
|
|
|
|
|
)));
|
|
|
|
|
|
|
|
|
|
$completedShopSearchDisplayQuery = $shopSearchDisplayQuery !== '' ? $shopSearchDisplayQuery : $shopSearchQuery;
|
|
|
|
|
$completedShopSearchIndividualQueries = $shopSearchDisplayIndividualQueries;
|
|
|
|
|
if (
|
|
|
|
|
$usedShopRepair
|
|
|
|
|
&& $shopRepairQueries !== []
|
|
|
|
|
&& $this->isReferentialProductListShopFollowUpPrompt($prompt)
|
|
|
|
|
) {
|
|
|
|
|
$completedShopSearchIndividualQueries = $shopRepairQueries;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$primaryShopSearchHadSystemFailure) {
|
|
|
|
|
yield $this->systemMsg(
|
|
|
|
|
$this->buildShopSearchMetaMessage(
|
|
|
|
|
query: $shopSearchDisplayQuery !== '' ? $shopSearchDisplayQuery : $shopSearchQuery,
|
|
|
|
|
query: $completedShopSearchDisplayQuery,
|
|
|
|
|
commerceIntent: $commerceIntent,
|
|
|
|
|
usedOptimizedQuery: $shopSearchUsedOptimizedQuery,
|
|
|
|
|
originalQuery: $shopSearchQuery,
|
|
|
|
|
resultCount: count($shopResults),
|
|
|
|
|
individualQueries: $completedShopSearchIndividualQueries,
|
|
|
|
|
completed: true,
|
|
|
|
|
attemptedRepair: $attemptedShopRepair,
|
|
|
|
|
usedRepair: $usedShopRepair
|
|
|
|
|
@@ -1534,6 +1593,119 @@ final readonly class AgentRunner
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve product-list follow-ups into individual product identity lookups
|
|
|
|
|
* before calling Shopware. This keeps referential prompts such as "links to
|
|
|
|
|
* the products" from sending a combined pseudo-query that Shopware cannot
|
|
|
|
|
* interpret as separate products.
|
|
|
|
|
*
|
|
|
|
|
* @param string[] $knowledgeChunks
|
|
|
|
|
* @return string[]
|
|
|
|
|
*/
|
|
|
|
|
private function resolveProductListFollowUpSplitLookupQueries(
|
|
|
|
|
string $prompt,
|
|
|
|
|
string $userId,
|
|
|
|
|
string $commerceHistoryContext,
|
|
|
|
|
array $knowledgeChunks
|
|
|
|
|
): array {
|
|
|
|
|
if (!$this->isReferentialProductListShopFollowUpPrompt($prompt)) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$anchors = $this->extractProductListFollowUpAnchorsForLookup(
|
|
|
|
|
commerceHistoryContext: $commerceHistoryContext,
|
|
|
|
|
userId: $userId,
|
|
|
|
|
knowledgeChunks: $knowledgeChunks
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (count($anchors) < 2) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->buildProductListFollowUpAnchorLookupQueries($anchors, '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Execute product-list follow-up lookups as actual separate Shopware
|
|
|
|
|
* searches. The execution is intentionally sequential and bounded by the
|
|
|
|
|
* configured max anchor count. The Store API client currently exposes a
|
|
|
|
|
* synchronous search contract; introducing concurrent transport here would
|
|
|
|
|
* be a wider service-level change and risk unrelated shop flows.
|
|
|
|
|
*
|
|
|
|
|
* @param string[] $queries
|
|
|
|
|
* @return array{results: array<int, ShopProductResult>, hadSystemFailure: bool, failureReason: ?string, queries: string[]}
|
|
|
|
|
*/
|
|
|
|
|
private function searchProductListFollowUpSplitLookupQueries(
|
|
|
|
|
string $prompt,
|
|
|
|
|
string $userId,
|
|
|
|
|
string $commerceIntent,
|
|
|
|
|
string $commerceHistoryContext,
|
|
|
|
|
array $queries
|
|
|
|
|
): array {
|
|
|
|
|
$queries = array_values(array_unique(array_filter(array_map(
|
|
|
|
|
static fn(string $query): string => trim($query),
|
|
|
|
|
$queries
|
|
|
|
|
), static fn(string $query): bool => $query !== '')));
|
|
|
|
|
|
|
|
|
|
$mergedResults = [];
|
|
|
|
|
$seenProducts = [];
|
|
|
|
|
$usedQueries = [];
|
|
|
|
|
$hadAnySystemFailure = false;
|
|
|
|
|
$hadAnySuccessfulSearch = false;
|
|
|
|
|
$failureReason = null;
|
|
|
|
|
|
|
|
|
|
foreach ($queries as $query) {
|
|
|
|
|
$queryResults = $this->searchShop(
|
|
|
|
|
$query,
|
|
|
|
|
$commerceIntent,
|
|
|
|
|
$userId,
|
|
|
|
|
$commerceHistoryContext
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if ($this->shopSearchService->hadLastSearchSystemFailure()) {
|
|
|
|
|
$hadAnySystemFailure = true;
|
|
|
|
|
$failureReason ??= $this->shopSearchService->getLastSearchFailureReason();
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$hadAnySuccessfulSearch = true;
|
|
|
|
|
$usedQueries[] = $query;
|
|
|
|
|
|
|
|
|
|
$identityResults = $this->filterProductListFollowUpResultsByAnchorIdentity($queryResults, [$query]);
|
|
|
|
|
foreach ($identityResults as $product) {
|
|
|
|
|
if (!$product instanceof ShopProductResult) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$key = $this->buildShopProductDedupeKey($product);
|
|
|
|
|
if (isset($seenProducts[$key])) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$seenProducts[$key] = true;
|
|
|
|
|
$mergedResults[] = $product;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->agentLogger->info('Executed product-list follow-up as separate product anchor shop searches', [
|
|
|
|
|
'userId' => $userId,
|
|
|
|
|
'commerceIntent' => $commerceIntent,
|
|
|
|
|
'prompt' => $prompt,
|
|
|
|
|
'queries' => $queries,
|
|
|
|
|
'usedQueries' => $usedQueries,
|
|
|
|
|
'resultCount' => count($mergedResults),
|
|
|
|
|
'hadAnySystemFailure' => $hadAnySystemFailure,
|
|
|
|
|
'hadAnySuccessfulSearch' => $hadAnySuccessfulSearch,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'results' => $mergedResults,
|
|
|
|
|
'hadSystemFailure' => $hadAnySystemFailure && !$hadAnySuccessfulSearch,
|
|
|
|
|
'failureReason' => $hadAnySystemFailure && !$hadAnySuccessfulSearch ? $failureReason : null,
|
|
|
|
|
'queries' => $usedQueries !== [] ? $usedQueries : $queries,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Keep referential product-list follow-ups aligned with the concrete product
|
|
|
|
|
* identities mentioned in the previous context. A combined query containing
|
|
|
|
|
@@ -1573,9 +1745,14 @@ final readonly class AgentRunner
|
|
|
|
|
return $repairPayload;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$identityResults = [];
|
|
|
|
|
$coveredAnchorKeys = [];
|
|
|
|
|
|
|
|
|
|
if ($currentResults !== []) {
|
|
|
|
|
$identityResults = $this->filterProductListFollowUpResultsByAnchorIdentity($currentResults, $anchors);
|
|
|
|
|
if ($identityResults !== []) {
|
|
|
|
|
$coveredAnchorKeys = $this->resolveProductListFollowUpCoveredAnchorKeys($identityResults, $anchors);
|
|
|
|
|
|
|
|
|
|
if ($identityResults !== [] && count($coveredAnchorKeys) >= count($anchors)) {
|
|
|
|
|
if (count($identityResults) !== count($currentResults)) {
|
|
|
|
|
$this->agentLogger->info('Filtered product-list follow-up shop results to referenced product identities', [
|
|
|
|
|
'userId' => $userId,
|
|
|
|
|
@@ -1599,8 +1776,22 @@ final readonly class AgentRunner
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$queries = $this->buildProductListFollowUpAnchorLookupQueries($anchors, $shopSearchQuery);
|
|
|
|
|
$missingAnchors = $this->filterProductListFollowUpMissingAnchors($anchors, $coveredAnchorKeys);
|
|
|
|
|
$lookupAnchors = count($anchors) > 1 && count($coveredAnchorKeys) < count($anchors)
|
|
|
|
|
? $anchors
|
|
|
|
|
: ($missingAnchors !== [] ? $missingAnchors : $anchors);
|
|
|
|
|
$queries = $this->buildProductListFollowUpAnchorLookupQueries($lookupAnchors, $shopSearchQuery);
|
|
|
|
|
|
|
|
|
|
if ($queries === []) {
|
|
|
|
|
if ($identityResults !== []) {
|
|
|
|
|
return [
|
|
|
|
|
'results' => $identityResults,
|
|
|
|
|
'attemptedRepair' => true,
|
|
|
|
|
'usedRepair' => true,
|
|
|
|
|
'repairQueries' => $repairPayload['repairQueries'] ?? [],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $currentResults === [] ? $repairPayload : [
|
|
|
|
|
'results' => [],
|
|
|
|
|
'attemptedRepair' => true,
|
|
|
|
|
@@ -1613,6 +1804,20 @@ final readonly class AgentRunner
|
|
|
|
|
$seenProducts = [];
|
|
|
|
|
$usedQueries = [];
|
|
|
|
|
|
|
|
|
|
foreach ($identityResults as $product) {
|
|
|
|
|
if (!$product instanceof ShopProductResult) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$key = $this->buildShopProductDedupeKey($product);
|
|
|
|
|
if (isset($seenProducts[$key])) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$seenProducts[$key] = true;
|
|
|
|
|
$mergedResults[] = $product;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($queries as $query) {
|
|
|
|
|
$queryResults = $this->searchShop(
|
|
|
|
|
$query,
|
|
|
|
|
@@ -1647,15 +1852,17 @@ final readonly class AgentRunner
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$repairQueries = array_values(array_unique(array_merge(
|
|
|
|
|
$repairPayload['repairQueries'] ?? [],
|
|
|
|
|
$queries
|
|
|
|
|
)));
|
|
|
|
|
|
|
|
|
|
if ($mergedResults === []) {
|
|
|
|
|
return [
|
|
|
|
|
'results' => [],
|
|
|
|
|
'attemptedRepair' => true,
|
|
|
|
|
'usedRepair' => false,
|
|
|
|
|
'repairQueries' => array_values(array_unique(array_merge(
|
|
|
|
|
$repairPayload['repairQueries'] ?? [],
|
|
|
|
|
$queries
|
|
|
|
|
))),
|
|
|
|
|
'repairQueries' => $repairQueries,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -1664,6 +1871,8 @@ final readonly class AgentRunner
|
|
|
|
|
'commerceIntent' => $commerceIntent,
|
|
|
|
|
'prompt' => $prompt,
|
|
|
|
|
'shopSearchQuery' => $shopSearchQuery,
|
|
|
|
|
'anchors' => $anchors,
|
|
|
|
|
'missingAnchors' => $missingAnchors,
|
|
|
|
|
'anchorLookupQueries' => $usedQueries,
|
|
|
|
|
'resultCount' => count($mergedResults),
|
|
|
|
|
]);
|
|
|
|
|
@@ -1672,10 +1881,7 @@ final readonly class AgentRunner
|
|
|
|
|
'results' => $mergedResults,
|
|
|
|
|
'attemptedRepair' => true,
|
|
|
|
|
'usedRepair' => true,
|
|
|
|
|
'repairQueries' => array_values(array_unique(array_merge(
|
|
|
|
|
$repairPayload['repairQueries'] ?? [],
|
|
|
|
|
$usedQueries
|
|
|
|
|
))),
|
|
|
|
|
'repairQueries' => $repairQueries,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -1718,21 +1924,15 @@ final readonly class AgentRunner
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
// Avoid a redundant retry only when the current query already is
|
|
|
|
|
// exactly the same focused product query. A combined multi-product
|
|
|
|
|
// query may contain all tokens of one anchor, but that still needs
|
|
|
|
|
// an individual lookup for the missing product identity.
|
|
|
|
|
if (
|
|
|
|
|
count($anchors) === 1
|
|
|
|
|
&& $this->buildProductListFollowUpAnchorKey($anchor) === implode(' ', array_keys($combinedTokens))
|
|
|
|
|
) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$queries[] = $anchor;
|
|
|
|
|
@@ -1741,6 +1941,66 @@ final readonly class AgentRunner
|
|
|
|
|
return array_values(array_unique($queries));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param ShopProductResult[] $shopResults
|
|
|
|
|
* @param string[] $anchors
|
|
|
|
|
* @return array<string, true>
|
|
|
|
|
*/
|
|
|
|
|
private function resolveProductListFollowUpCoveredAnchorKeys(array $shopResults, array $anchors): array
|
|
|
|
|
{
|
|
|
|
|
$covered = [];
|
|
|
|
|
$anchors = $this->normalizeProductListFollowUpAnchors($anchors);
|
|
|
|
|
|
|
|
|
|
if ($shopResults === [] || $anchors === []) {
|
|
|
|
|
return $covered;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($shopResults as $product) {
|
|
|
|
|
if (!$product instanceof ShopProductResult) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($anchors as $anchor) {
|
|
|
|
|
if (!$this->shopProductIdentityMatchesProductListAnchor($product, $anchor)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$key = $this->buildProductListFollowUpAnchorKey($anchor);
|
|
|
|
|
if ($key !== '') {
|
|
|
|
|
$covered[$key] = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $covered;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param string[] $anchors
|
|
|
|
|
* @param array<string, true> $coveredAnchorKeys
|
|
|
|
|
* @return string[]
|
|
|
|
|
*/
|
|
|
|
|
private function filterProductListFollowUpMissingAnchors(array $anchors, array $coveredAnchorKeys): array
|
|
|
|
|
{
|
|
|
|
|
$missing = [];
|
|
|
|
|
|
|
|
|
|
foreach ($this->normalizeProductListFollowUpAnchors($anchors) as $anchor) {
|
|
|
|
|
$key = $this->buildProductListFollowUpAnchorKey($anchor);
|
|
|
|
|
if ($key === '' || isset($coveredAnchorKeys[$key])) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$missing[] = $anchor;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $missing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function buildProductListFollowUpAnchorKey(string $anchor): string
|
|
|
|
|
{
|
|
|
|
|
return implode(' ', $this->tokenizeShopQueryCandidate($anchor));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param string[] $anchors
|
|
|
|
|
* @return string[]
|
|
|
|
|
@@ -2998,7 +3258,7 @@ final readonly class AgentRunner
|
|
|
|
|
// A standalone query optimizer may remove words, but it must not add
|
|
|
|
|
// model numbers or article-like numbers that are absent from the
|
|
|
|
|
// current user input. Otherwise old context can leak into new shop
|
|
|
|
|
// searches, for example "Anschlusskabel pH/Redox" -> "testomat 808".
|
|
|
|
|
// searches.
|
|
|
|
|
if (preg_match('/\d/u', $token) === 1) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
@@ -3765,20 +4025,26 @@ final readonly class AgentRunner
|
|
|
|
|
|
|
|
|
|
private function canonicalizeProductListAnchor(string $anchor): string
|
|
|
|
|
{
|
|
|
|
|
$anchor = $this->trimProductListAnchorToConfiguredStart($anchor);
|
|
|
|
|
|
|
|
|
|
$tokens = $this->tokenizeShopQueryCandidate($anchor);
|
|
|
|
|
if ($tokens === []) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (($tokens[0] ?? '') !== 'testomat') {
|
|
|
|
|
$familyTerms = $this->buildShopQueryTokenSet(
|
|
|
|
|
$this->agentRunnerConfig->getShopQueryProductListFollowUpCanonicalFamilyTerms()
|
|
|
|
|
);
|
|
|
|
|
$familyToken = (string) ($tokens[0] ?? '');
|
|
|
|
|
if ($familyTerms === [] || !isset($familyTerms[$familyToken])) {
|
|
|
|
|
return trim((string) preg_replace('/\s+/u', ' ', $anchor));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isset($tokens[1])) {
|
|
|
|
|
return 'testomat';
|
|
|
|
|
return $familyToken;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$canonical = ['testomat', $tokens[1]];
|
|
|
|
|
$canonical = [$familyToken, $tokens[1]];
|
|
|
|
|
$variantTerms = $this->buildShopQueryTokenSet(
|
|
|
|
|
$this->agentRunnerConfig->getShopQueryPositiveTokenFilterAdjacentVariantTerms()
|
|
|
|
|
);
|
|
|
|
|
@@ -3797,6 +4063,36 @@ final readonly class AgentRunner
|
|
|
|
|
return trim(implode(' ', $canonical));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function trimProductListAnchorToConfiguredStart(string $anchor): string
|
|
|
|
|
{
|
|
|
|
|
$anchor = trim($anchor);
|
|
|
|
|
if ($anchor === '') {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($this->agentRunnerConfig->getShopQueryProductListFollowUpCanonicalStartPatterns() as $pattern) {
|
|
|
|
|
if (@preg_match($pattern, $anchor, $match) !== 1) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$candidate = '';
|
|
|
|
|
if (isset($match['anchor']) && is_string($match['anchor'])) {
|
|
|
|
|
$candidate = $match['anchor'];
|
|
|
|
|
} elseif (isset($match[1]) && is_string($match[1])) {
|
|
|
|
|
$candidate = $match[1];
|
|
|
|
|
} elseif (isset($match[0]) && is_string($match[0])) {
|
|
|
|
|
$candidate = $match[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$candidate = trim((string) preg_replace('/\s+/u', ' ', $candidate));
|
|
|
|
|
if ($candidate !== '') {
|
|
|
|
|
return $candidate;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return trim((string) preg_replace('/\s+/u', ' ', $anchor));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function guardMainDeviceReferentialShopQueryWithHistoryModelAnchor(
|
|
|
|
|
string $prompt,
|
|
|
|
|
string $shopSearchQuery,
|
|
|
|
|
@@ -6903,6 +7199,9 @@ final readonly class AgentRunner
|
|
|
|
|
return $this->normalizeOneLine($rendered);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param string[] $individualQueries Actual individual Shopware queries that were prepared/executed for split product-list follow-ups.
|
|
|
|
|
*/
|
|
|
|
|
private function buildShopSearchMetaMessage(
|
|
|
|
|
string $query,
|
|
|
|
|
string $commerceIntent,
|
|
|
|
|
@@ -6912,10 +7211,12 @@ final readonly class AgentRunner
|
|
|
|
|
bool $completed = false,
|
|
|
|
|
bool $attemptedRepair = false,
|
|
|
|
|
bool $usedRepair = false,
|
|
|
|
|
bool $unavailable = false
|
|
|
|
|
bool $unavailable = false,
|
|
|
|
|
array $individualQueries = []
|
|
|
|
|
): string {
|
|
|
|
|
$query = $this->normalizeOneLine($query);
|
|
|
|
|
$originalQuery = $this->normalizeOneLine($originalQuery);
|
|
|
|
|
$individualQueries = $this->normalizeProductListFollowUpIndividualQueriesForDisplay($individualQueries);
|
|
|
|
|
|
|
|
|
|
if ($query === '') {
|
|
|
|
|
$query = $originalQuery !== '' ? $originalQuery : $this->agentRunnerConfig->getProductionUiText('shop_meta_fallback_query');
|
|
|
|
|
@@ -6963,15 +7264,56 @@ final readonly class AgentRunner
|
|
|
|
|
$html .= '<span class="retriex-meta-pill">' . htmlspecialchars($repairLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$html .= '</div>'
|
|
|
|
|
. '<div class="retriex-meta-query"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><code>'
|
|
|
|
|
. htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
|
|
|
. '</code></div>'
|
|
|
|
|
. '</div>';
|
|
|
|
|
$html .= '</div>';
|
|
|
|
|
|
|
|
|
|
if ($individualQueries !== []) {
|
|
|
|
|
$html .= '<div class="retriex-meta-query retriex-meta-query--multi"><span>'
|
|
|
|
|
. htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_queries_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
|
|
|
. '</span><span class="retriex-meta-query__list">';
|
|
|
|
|
|
|
|
|
|
foreach ($individualQueries as $individualQuery) {
|
|
|
|
|
$html .= '<code>' . htmlspecialchars($individualQuery, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$html .= '</span></div>';
|
|
|
|
|
} else {
|
|
|
|
|
$html .= '<div class="retriex-meta-query"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><code>'
|
|
|
|
|
. htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
|
|
|
. '</code></div>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$html .= '</div>';
|
|
|
|
|
|
|
|
|
|
return $html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param string[] $queries
|
|
|
|
|
* @return string[]
|
|
|
|
|
*/
|
|
|
|
|
private function normalizeProductListFollowUpIndividualQueriesForDisplay(array $queries): array
|
|
|
|
|
{
|
|
|
|
|
$normalized = [];
|
|
|
|
|
$seen = [];
|
|
|
|
|
|
|
|
|
|
foreach ($queries as $query) {
|
|
|
|
|
$query = $this->normalizeOneLine((string) $query);
|
|
|
|
|
if ($query === '') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$key = mb_strtolower($query, 'UTF-8');
|
|
|
|
|
if (isset($seen[$key])) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$seen[$key] = true;
|
|
|
|
|
$normalized[] = $query;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function buildShopUnavailableMessage(?string $reason): string
|
|
|
|
|
{
|
|
|
|
|
$reason = $this->normalizeOneLine((string) $reason);
|
|
|
|
|
|