harden history find tokens and shops earch
This commit is contained in:
@@ -121,84 +121,108 @@ final readonly class AgentRunner
|
||||
$commerceHistoryContext
|
||||
);
|
||||
|
||||
$shopSearchQuery = $optimizedShopQuery !== '' ? $optimizedShopQuery : $prompt;
|
||||
|
||||
$shopQueryPreview = $this->shopSearchService->buildSearchQueryPreview(
|
||||
$shopSearchQuery,
|
||||
$commerceIntent,
|
||||
$commerceHistoryContext
|
||||
$shopSearchQuery = $this->resolveShopSearchQuery(
|
||||
prompt: $prompt,
|
||||
optimizedShopQuery: $optimizedShopQuery,
|
||||
commerceHistoryContext: $commerceHistoryContext,
|
||||
userId: $userId
|
||||
);
|
||||
|
||||
yield $this->systemMsg(
|
||||
$this->buildShopSearchMetaMessage(
|
||||
query: $shopQueryPreview->searchText !== '' ? $shopQueryPreview->searchText : $shopSearchQuery,
|
||||
commerceIntent: $commerceIntent,
|
||||
usedOptimizedQuery: $optimizedShopQuery !== '',
|
||||
originalQuery: $shopSearchQuery
|
||||
),
|
||||
'meta'
|
||||
);
|
||||
|
||||
$this->agentLogger->info('Commerce search prepared', [
|
||||
'userId' => $userId,
|
||||
'commerceIntent' => $commerceIntent,
|
||||
'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
|
||||
'optimizedShopQuery' => $optimizedShopQuery,
|
||||
'shopSearchQuery' => $shopSearchQuery,
|
||||
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
|
||||
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
||||
]);
|
||||
|
||||
yield $this->systemMsg(
|
||||
sprintf($this->agentRunnerConfig->getFetchSearchDataMessageTemplate(), $commerceIntent),
|
||||
'think'
|
||||
);
|
||||
|
||||
$primaryShopResults = $this->searchShop(
|
||||
$shopSearchQuery,
|
||||
$commerceIntent,
|
||||
$userId,
|
||||
$commerceHistoryContext
|
||||
);
|
||||
$primaryShopSearchHadSystemFailure = $this->shopSearchService->hadLastSearchSystemFailure();
|
||||
$primaryShopSearchFailureReason = $this->shopSearchService->getLastSearchFailureReason();
|
||||
|
||||
if ($primaryShopSearchHadSystemFailure) {
|
||||
$this->agentLogger->warning('Shop repair skipped after Store API system failure', [
|
||||
if ($shopSearchQuery === '') {
|
||||
$this->agentLogger->info('Commerce search skipped because no concrete shop query could be resolved', [
|
||||
'userId' => $userId,
|
||||
'commerceIntent' => $commerceIntent,
|
||||
'shopSearchQuery' => $shopSearchQuery,
|
||||
'failureReason' => $primaryShopSearchFailureReason,
|
||||
'prompt' => $prompt,
|
||||
'optimizedShopQuery' => $optimizedShopQuery,
|
||||
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
|
||||
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
||||
]);
|
||||
|
||||
$shopUnavailableMessage = $this->buildShopUnavailableMessage($primaryShopSearchFailureReason);
|
||||
yield $this->systemMsg(
|
||||
$shopUnavailableMessage,
|
||||
'err'
|
||||
);
|
||||
$historyNotices[] = $this->buildHistoryNotice(
|
||||
'Shopdaten konnten nicht geladen werden',
|
||||
$primaryShopSearchFailureReason
|
||||
$this->agentRunnerConfig->getNoConcreteShopQueryMessage(),
|
||||
'info'
|
||||
);
|
||||
|
||||
$repairPayload = [
|
||||
'results' => $primaryShopResults,
|
||||
'attemptedRepair' => false,
|
||||
'usedRepair' => false,
|
||||
'repairQueries' => [],
|
||||
];
|
||||
|
||||
return;
|
||||
} else {
|
||||
yield $this->systemMsg('Erweiterte Shopsuche wird geprüft…', 'think');
|
||||
|
||||
$repairPayload = $this->repairShopResults(
|
||||
prompt: $prompt,
|
||||
userId: $userId,
|
||||
commerceIntent: $commerceIntent,
|
||||
commerceHistoryContext: $commerceHistoryContext,
|
||||
primaryQuery: $shopSearchQuery,
|
||||
primaryShopResults: $primaryShopResults,
|
||||
knowledgeChunks: $knowledgeChunks
|
||||
$shopQueryPreview = $this->shopSearchService->buildSearchQueryPreview(
|
||||
$shopSearchQuery,
|
||||
$commerceIntent,
|
||||
$commerceHistoryContext
|
||||
);
|
||||
|
||||
yield $this->systemMsg(
|
||||
$this->buildShopSearchMetaMessage(
|
||||
query: $shopQueryPreview->searchText !== '' ? $shopQueryPreview->searchText : $shopSearchQuery,
|
||||
commerceIntent: $commerceIntent,
|
||||
usedOptimizedQuery: $optimizedShopQuery !== '',
|
||||
originalQuery: $shopSearchQuery
|
||||
),
|
||||
'meta'
|
||||
);
|
||||
|
||||
$this->agentLogger->info('Commerce search prepared', [
|
||||
'userId' => $userId,
|
||||
'commerceIntent' => $commerceIntent,
|
||||
'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
|
||||
'optimizedShopQuery' => $optimizedShopQuery,
|
||||
'shopSearchQuery' => $shopSearchQuery,
|
||||
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
|
||||
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
||||
]);
|
||||
|
||||
yield $this->systemMsg(
|
||||
sprintf($this->agentRunnerConfig->getFetchSearchDataMessageTemplate(), $commerceIntent),
|
||||
'think'
|
||||
);
|
||||
|
||||
$primaryShopResults = $this->searchShop(
|
||||
$shopSearchQuery,
|
||||
$commerceIntent,
|
||||
$userId,
|
||||
$commerceHistoryContext
|
||||
);
|
||||
$primaryShopSearchHadSystemFailure = $this->shopSearchService->hadLastSearchSystemFailure();
|
||||
$primaryShopSearchFailureReason = $this->shopSearchService->getLastSearchFailureReason();
|
||||
|
||||
if ($primaryShopSearchHadSystemFailure) {
|
||||
$this->agentLogger->warning('Shop repair skipped after Store API system failure', [
|
||||
'userId' => $userId,
|
||||
'commerceIntent' => $commerceIntent,
|
||||
'shopSearchQuery' => $shopSearchQuery,
|
||||
'failureReason' => $primaryShopSearchFailureReason,
|
||||
]);
|
||||
|
||||
$shopUnavailableMessage = $this->buildShopUnavailableMessage($primaryShopSearchFailureReason);
|
||||
yield $this->systemMsg(
|
||||
$shopUnavailableMessage,
|
||||
'err'
|
||||
);
|
||||
$historyNotices[] = $this->buildHistoryNotice(
|
||||
'Shopdaten konnten nicht geladen werden',
|
||||
$primaryShopSearchFailureReason
|
||||
);
|
||||
|
||||
$repairPayload = [
|
||||
'results' => $primaryShopResults,
|
||||
'attemptedRepair' => false,
|
||||
'usedRepair' => false,
|
||||
'repairQueries' => [],
|
||||
];
|
||||
} else {
|
||||
yield $this->systemMsg('Erweiterte Shopsuche wird geprüft…', 'think');
|
||||
|
||||
$repairPayload = $this->repairShopResults(
|
||||
prompt: $prompt,
|
||||
userId: $userId,
|
||||
commerceIntent: $commerceIntent,
|
||||
commerceHistoryContext: $commerceHistoryContext,
|
||||
primaryQuery: $shopSearchQuery,
|
||||
primaryShopResults: $primaryShopResults,
|
||||
knowledgeChunks: $knowledgeChunks
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$shopResults = $repairPayload['results'];
|
||||
@@ -645,7 +669,7 @@ final readonly class AgentRunner
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->sanitizeOptimizedShopQuery($optimizedQuery);
|
||||
return $this->sanitizeOptimizedShopQuery($optimizedQuery, $prompt, $commerceHistoryContext);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -692,6 +716,189 @@ final readonly class AgentRunner
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveShopSearchQuery(
|
||||
string $prompt,
|
||||
string $optimizedShopQuery,
|
||||
string $commerceHistoryContext,
|
||||
string $userId
|
||||
): string {
|
||||
if ($optimizedShopQuery !== '' && !$this->isMetaOnlyShopQuery($optimizedShopQuery)) {
|
||||
return $optimizedShopQuery;
|
||||
}
|
||||
|
||||
if (!$this->isMetaOnlyShopQuery($prompt)) {
|
||||
return $prompt;
|
||||
}
|
||||
|
||||
$contextQuery = $this->extractContextualShopSearchQuery($commerceHistoryContext);
|
||||
|
||||
if ($contextQuery !== '' && !$this->isMetaOnlyShopQuery($contextQuery)) {
|
||||
return $contextQuery;
|
||||
}
|
||||
|
||||
$extendedHistoryBudget = $this->agentRunnerConfig->getShopQueryContextFallbackHistoryBudgetChars();
|
||||
|
||||
if ($extendedHistoryBudget > mb_strlen($commerceHistoryContext, 'UTF-8')) {
|
||||
$extendedHistory = $this->contextService->buildUserContextWithinBudget($userId, $extendedHistoryBudget);
|
||||
$extendedContextQuery = $this->extractContextualShopSearchQuery($extendedHistory);
|
||||
|
||||
if ($extendedContextQuery !== '' && !$this->isMetaOnlyShopQuery($extendedContextQuery)) {
|
||||
return $extendedContextQuery;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->agentRunnerConfig->shouldUseFullHistoryForShopQueryContextFallback()) {
|
||||
$fullHistory = $this->contextService->buildUserContext($userId, true);
|
||||
$fullHistoryContextQuery = $this->extractContextualShopSearchQuery($fullHistory);
|
||||
|
||||
if ($fullHistoryContextQuery !== '' && !$this->isMetaOnlyShopQuery($fullHistoryContextQuery)) {
|
||||
return $fullHistoryContextQuery;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function extractContextualShopSearchQuery(string $commerceHistoryContext): string
|
||||
{
|
||||
if (!$this->agentRunnerConfig->isShopQueryContextFallbackEnabled()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$questions = $this->extractRecentUserQuestions(
|
||||
$commerceHistoryContext,
|
||||
$this->agentRunnerConfig->getShopQueryContextFallbackQuestionLimit()
|
||||
);
|
||||
|
||||
for ($i = count($questions) - 1; $i >= 0; $i--) {
|
||||
$question = trim($questions[$i]);
|
||||
|
||||
if ($question === '' || $this->isMetaOnlyShopQuery($question)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contextQuery = $this->buildContextFallbackShopQuery($question);
|
||||
|
||||
if ($contextQuery !== '' && !$this->isMetaOnlyShopQuery($contextQuery)) {
|
||||
return $contextQuery;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function buildContextFallbackShopQuery(string $question): string
|
||||
{
|
||||
$tokens = $this->tokenizeShopQueryCandidate($question);
|
||||
|
||||
if ($tokens === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$filterTerms = [];
|
||||
|
||||
foreach (array_merge(
|
||||
$this->agentRunnerConfig->getShopQueryMetaOnlyTerms(),
|
||||
$this->agentRunnerConfig->getShopQueryContextFallbackFilterTerms()
|
||||
) as $term) {
|
||||
foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
|
||||
$filterTerms[$token] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$maxTerms = max(1, $this->agentRunnerConfig->getShopQueryContextFallbackMaxTerms());
|
||||
$out = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (isset($filterTerms[$token])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($token, $out, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = $token;
|
||||
|
||||
if (count($out) >= $maxTerms) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(' ', $out);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function tokenizeShopQueryCandidate(string $value): array
|
||||
{
|
||||
$value = mb_strtolower(trim($value), 'UTF-8');
|
||||
$value = str_replace(['-', '/', '_'], ' ', $value);
|
||||
|
||||
if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', $value, $matches) !== 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(static fn(string $token): string => trim($token), $matches[0] ?? []),
|
||||
static fn(string $token): bool => $token !== ''
|
||||
));
|
||||
}
|
||||
|
||||
private function isMetaOnlyShopQuery(string $query): bool
|
||||
{
|
||||
if (!$this->agentRunnerConfig->isShopQueryMetaGuardEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokens = $this->tokenizeMetaGuardText($query);
|
||||
|
||||
if ($tokens === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$metaTerms = [];
|
||||
foreach ($this->agentRunnerConfig->getShopQueryMetaOnlyTerms() as $term) {
|
||||
foreach ($this->tokenizeMetaGuardText($term) as $token) {
|
||||
$metaTerms[$token] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($metaTerms === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (!isset($metaTerms[$token])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function tokenizeMetaGuardText(string $value): array
|
||||
{
|
||||
$value = mb_strtolower(trim($value), 'UTF-8');
|
||||
$value = str_replace(['-', '/', '_'], ' ', $value);
|
||||
$value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
|
||||
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
explode(' ', $value),
|
||||
static fn(string $token): bool => $token !== ''
|
||||
));
|
||||
}
|
||||
|
||||
private function searchShop(
|
||||
string $query,
|
||||
string $commerceIntent,
|
||||
@@ -743,8 +950,11 @@ final readonly class AgentRunner
|
||||
};
|
||||
}
|
||||
|
||||
private function sanitizeOptimizedShopQuery(string $query): string
|
||||
{
|
||||
private function sanitizeOptimizedShopQuery(
|
||||
string $query,
|
||||
string $sourcePrompt = '',
|
||||
string $commerceHistoryContext = ''
|
||||
): string {
|
||||
$query = trim($query);
|
||||
|
||||
if ($query === '') {
|
||||
@@ -755,10 +965,162 @@ final readonly class AgentRunner
|
||||
$query = preg_replace($this->agentRunnerConfig->getOptimizedShopQueryPrefixPattern(), '', $query) ?? $query;
|
||||
$query = trim($query, $this->agentRunnerConfig->getOptimizedShopQueryTrimCharacters());
|
||||
$query = preg_replace('/\s+/u', ' ', $query) ?? $query;
|
||||
$query = $this->preserveOptimizedShopQueryLanguage($query, $sourcePrompt);
|
||||
$query = $this->enrichReferentialShopQueryFromHistory($query, $sourcePrompt, $commerceHistoryContext);
|
||||
$query = preg_replace('/\s+/u', ' ', $query) ?? $query;
|
||||
|
||||
return trim($query);
|
||||
}
|
||||
|
||||
private function enrichReferentialShopQueryFromHistory(
|
||||
string $query,
|
||||
string $sourcePrompt,
|
||||
string $commerceHistoryContext
|
||||
): string {
|
||||
if (!$this->agentRunnerConfig->isShopQueryContextAnchorEnrichmentEnabled()) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
if (trim($commerceHistoryContext) === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$queryTokens = $this->tokenizeShopQueryCandidate($query);
|
||||
|
||||
if ($queryTokens === []) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$maxTerms = max(1, $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentMaxQueryTerms());
|
||||
if (count($queryTokens) > $maxTerms) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
if (!$this->containsConfiguredShopQueryAnchorTrigger(trim($query . ' ' . $sourcePrompt))) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$anchor = $this->normalizeShopQueryAnchor(
|
||||
$this->extractLatestConfiguredShopQueryContextAnchor($commerceHistoryContext)
|
||||
);
|
||||
|
||||
if ($anchor === '' || $this->queryAlreadyContainsAllAnchorTokens($query, $anchor)) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$template = $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTemplate();
|
||||
$enriched = str_replace(['{anchor}', '{query}'], [$anchor, $query], $template);
|
||||
$enriched = preg_replace('/\s+/u', ' ', $enriched) ?? $enriched;
|
||||
|
||||
return trim($enriched) !== '' ? trim($enriched) : $query;
|
||||
}
|
||||
|
||||
private function containsConfiguredShopQueryAnchorTrigger(string $text): bool
|
||||
{
|
||||
$tokens = $this->tokenizeShopQueryCandidate($text);
|
||||
|
||||
if ($tokens === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokenSet = array_fill_keys($tokens, true);
|
||||
|
||||
foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTriggerTerms() as $term) {
|
||||
foreach ($this->tokenizeShopQueryCandidate($term) as $termToken) {
|
||||
if (isset($tokenSet[$termToken])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function extractLatestConfiguredShopQueryContextAnchor(string $commerceHistoryContext): string
|
||||
{
|
||||
$latest = '';
|
||||
|
||||
foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentPatterns() as $pattern) {
|
||||
if (@preg_match_all($pattern, $commerceHistoryContext, $matches, PREG_SET_ORDER) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$candidate = trim((string) ($match[0] ?? ''));
|
||||
if ($candidate !== '') {
|
||||
$latest = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $latest;
|
||||
}
|
||||
|
||||
private function normalizeShopQueryAnchor(string $anchor): string
|
||||
{
|
||||
$anchor = str_replace('®', '', $anchor);
|
||||
$anchor = mb_strtolower(trim($anchor), 'UTF-8');
|
||||
$anchor = preg_replace('/[^\p{L}\p{N},.%°+\-\s]+/u', ' ', $anchor) ?? $anchor;
|
||||
$anchor = preg_replace('/\s+/u', ' ', $anchor) ?? $anchor;
|
||||
|
||||
return trim($anchor);
|
||||
}
|
||||
|
||||
private function queryAlreadyContainsAllAnchorTokens(string $query, string $anchor): bool
|
||||
{
|
||||
$queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($query), true);
|
||||
|
||||
foreach ($this->tokenizeShopQueryCandidate($anchor) as $token) {
|
||||
if (!isset($queryTokens[$token])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function preserveOptimizedShopQueryLanguage(string $query, string $sourcePrompt): string
|
||||
{
|
||||
if (!$this->agentRunnerConfig->isShopQueryLanguagePreservationEnabled()) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$language = $this->detectConfiguredShopQueryLanguage($sourcePrompt);
|
||||
|
||||
if ($language === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$replacements = $this->agentRunnerConfig->getShopQueryTranslationReplacements($language);
|
||||
|
||||
if ($replacements === []) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
foreach ($replacements as $source => $target) {
|
||||
$pattern = '/(?<![\\p{L}\\p{N}])' . preg_replace('/\\s+/u', '\\s+', preg_quote($source, '/')) . '(?![\\p{L}\\p{N}])/iu';
|
||||
$query = preg_replace($pattern, $target, $query) ?? $query;
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function detectConfiguredShopQueryLanguage(string $sourcePrompt): ?string
|
||||
{
|
||||
$normalized = ' ' . strtolower($sourcePrompt) . ' ';
|
||||
$normalized = preg_replace('/[\\r\\n\\t]+/u', ' ', $normalized) ?? $normalized;
|
||||
$normalized = preg_replace('/\\s+/u', ' ', $normalized) ?? $normalized;
|
||||
|
||||
foreach ($this->agentRunnerConfig->getShopQueryLanguageMarkers() as $language => $markers) {
|
||||
foreach ($markers as $marker) {
|
||||
if ($marker !== '' && str_contains($normalized, $marker)) {
|
||||
return $language;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* @return Generator<int, string, mixed, string>
|
||||
*/
|
||||
@@ -993,4 +1355,4 @@ final readonly class AgentRunner
|
||||
default => $msg,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user