This commit is contained in:
team 1
2026-05-10 18:03:56 +02:00
parent 36485027e6
commit fbd8de64d0
12 changed files with 745 additions and 67 deletions

View File

@@ -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);

View File

@@ -1863,6 +1863,22 @@ final class AgentRunnerConfig
{
return $this->genreStringList('context_resolution.product_list_followup.anchor_patterns');
}
/**
* @return string[]
*/
public function getShopQueryProductListFollowUpCanonicalStartPatterns(): array
{
return $this->genreStringList('context_resolution.product_list_followup.canonical_start_patterns');
}
/**
* @return string[]
*/
public function getShopQueryProductListFollowUpCanonicalFamilyTerms(): array
{
return $this->genreStringList('context_resolution.product_list_followup.canonical_family_terms');
}
public function isShopQueryRagAnchorEnrichmentEnabled(): bool
{
return $this->getRequiredBool('shop_runtime.context_resolution.rag_anchor_enrichment.enabled');

View File

@@ -411,6 +411,7 @@ final class ChatMessagesConfig
'agent.production_ui.text.shop_meta_repair_checked',
'agent.production_ui.text.shop_meta_eyebrow',
'agent.production_ui.text.shop_meta_query_label',
'agent.production_ui.text.shop_meta_queries_label',
'agent.production_ui.text.shop_meta_query_prefix',
'agent.production_ui.text.shop_meta_intent_prefix',
'agent.production_ui.text.shop_unavailable_default_reason',

View File

@@ -1370,7 +1370,9 @@ final readonly class RetriexEffectiveConfigProvider
$this->validateStringList($this->toList($productListFollowUp['product_terms'] ?? []), 'genre.configuration_values.context_resolution.product_list_followup.product_terms', $errors, $warnings);
$this->validateStringList($this->toList($productListFollowUp['shop_terms'] ?? []), 'genre.configuration_values.context_resolution.product_list_followup.shop_terms', $errors, $warnings);
$this->validateStringList($this->toList($productListFollowUp['noise_terms'] ?? []), 'genre.configuration_values.context_resolution.product_list_followup.noise_terms', $errors, $warnings);
$this->validateStringList($this->toList($productListFollowUp['canonical_family_terms'] ?? []), 'genre.configuration_values.context_resolution.product_list_followup.canonical_family_terms', $errors, $warnings);
$this->validateRegexPatternList($productListFollowUp['anchor_patterns'] ?? [], 'genre.configuration_values.context_resolution.product_list_followup.anchor_patterns', $errors);
$this->validateRegexPatternList($productListFollowUp['canonical_start_patterns'] ?? [], 'genre.configuration_values.context_resolution.product_list_followup.canonical_start_patterns', $errors);
}
$shopQueryRuntime = is_array($configurationValues['shop_query_runtime'] ?? null)