5783 lines
207 KiB
PHP
5783 lines
207 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Agent;
|
|
|
|
use App\Commerce\Dto\ShopProductResult;
|
|
use App\Commerce\ProductRoleResolver;
|
|
use App\Commerce\SearchRepairService;
|
|
use App\Commerce\ShopSearchService;
|
|
use App\Config\AgentRunnerConfig;
|
|
use App\Config\LanguageCleanupConfig;
|
|
use App\Context\ContextService;
|
|
use App\Context\UrlAnalyzer;
|
|
use App\Infrastructure\OllamaClient;
|
|
use App\Intent\CommerceIntentLite;
|
|
use App\Knowledge\Retrieval\RetrieverInterface;
|
|
use Generator;
|
|
use Psr\Log\LoggerInterface;
|
|
use Throwable;
|
|
|
|
final readonly class AgentRunner
|
|
{
|
|
private bool $systemMsgOn;
|
|
|
|
public function __construct(
|
|
private PromptBuilder $promptBuilder,
|
|
private ThinkSuppressor $thinkSuppressor,
|
|
private ContextService $contextService,
|
|
private UrlAnalyzer $urlAnalyzer,
|
|
private RetrieverInterface $retriever,
|
|
private ShopSearchService $shopSearchService,
|
|
private SearchRepairService $searchRepairService,
|
|
private ReferenceAnchorExtractor $referenceAnchorExtractor,
|
|
private CommerceIntentLite $commerceIntentLite,
|
|
private OllamaClient $ollamaClient,
|
|
private LoggerInterface $agentLogger,
|
|
private AgentRunnerConfig $agentRunnerConfig,
|
|
private LanguageCleanupConfig $languageCleanupConfig,
|
|
private bool $debug,
|
|
private bool $logPrompt,
|
|
private bool $logContext,
|
|
) {
|
|
$this->systemMsgOn = true;
|
|
}
|
|
|
|
public function run(string $prompt, string $userId, bool $forceFullContext = false, string $requestContextHint = ''): Generator
|
|
{
|
|
$originalPrompt = trim($prompt);
|
|
$prompt = $originalPrompt;
|
|
$routingPrompt = $prompt;
|
|
|
|
if ($prompt === '') {
|
|
yield $this->systemMsg($this->agentRunnerConfig->getEmptyPromptMessage(), 'err');
|
|
return;
|
|
}
|
|
|
|
$shopResults = [];
|
|
$primaryShopResults = [];
|
|
$knowledgeChunks = [];
|
|
$knowledgeEvidenceState = 'none';
|
|
$sources = [];
|
|
$optimizedShopQuery = '';
|
|
$shopSearchQuery = '';
|
|
$commerceHistoryContext = '';
|
|
$shopQueryHistoryContext = '';
|
|
$attemptedShopRepair = false;
|
|
$usedShopRepair = false;
|
|
$shopRepairQueries = [];
|
|
$shopSearchAttempted = false;
|
|
$primaryShopSearchHadSystemFailure = false;
|
|
$historyNotices = [];
|
|
$commerceIntent = CommerceIntentLite::NONE;
|
|
$shopSearchSkippedBecauseNoQuery = false;
|
|
|
|
$this->agentLogger->info('Agent run started', [
|
|
'userId' => $userId,
|
|
]);
|
|
|
|
try {
|
|
if ($forceFullContext) {
|
|
// Full context mode is already passed to PromptBuilder.
|
|
// Additional context strategies can be added here later.
|
|
}
|
|
|
|
yield $this->systemMsg(
|
|
$this->buildProductionUiMetaMessage(
|
|
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('preparing_answer'),
|
|
ragCount: null,
|
|
shopCount: null,
|
|
shopCountMode: $this->resolveShopCountModeForMeta(
|
|
commerceIntent: $commerceIntent,
|
|
shopSearchAttempted: $shopSearchAttempted,
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
|
|
),
|
|
sourceLabels: $sources,
|
|
confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('checking_evidence')
|
|
),
|
|
'meta'
|
|
);
|
|
|
|
yield $this->systemMsg($this->agentRunnerConfig->getAnalyzeRequestMessage(), 'think');
|
|
|
|
$normalizedPrompt = yield from $this->normalizePromptForRouting($prompt, $userId);
|
|
if ($normalizedPrompt !== $prompt) {
|
|
$this->agentLogger->info('Prompt normalized before routing', [
|
|
'userId' => $userId,
|
|
'originalPrompt' => $prompt,
|
|
'normalizedPrompt' => $normalizedPrompt,
|
|
]);
|
|
$routingPrompt = $normalizedPrompt;
|
|
}
|
|
|
|
yield $this->systemMsg($this->agentRunnerConfig->getCheckInternetSourcesMessage(), 'think');
|
|
|
|
$urlContent = $this->urlAnalyzer->extractContentFromPrompt($originalPrompt);
|
|
if ($urlContent !== '') {
|
|
$this->addSource($sources, $this->agentRunnerConfig->getExternalUrlSourceLabel());
|
|
}
|
|
|
|
$commerceIntent = $this->detectCommerceIntentForRouting(
|
|
$routingPrompt,
|
|
$userId,
|
|
$requestContextHint
|
|
);
|
|
$originalCommerceIntent = $this->detectCommerceIntentForRouting(
|
|
$originalPrompt,
|
|
$userId,
|
|
$requestContextHint
|
|
);
|
|
|
|
if (!$this->isCommerceIntent($commerceIntent) && $this->isCommerceIntent($originalCommerceIntent)) {
|
|
$this->agentLogger->info('Promoted normalized routing back to explicit original commerce intent', [
|
|
'userId' => $userId,
|
|
'originalPrompt' => $originalPrompt,
|
|
'routingPrompt' => $routingPrompt,
|
|
'originalCommerceIntent' => $originalCommerceIntent,
|
|
]);
|
|
$commerceIntent = $originalCommerceIntent;
|
|
}
|
|
|
|
if ($this->isCommerceIntent($commerceIntent)) {
|
|
yield $this->systemMsg(
|
|
$this->buildProductionUiMetaMessage(
|
|
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_routing_detected'),
|
|
ragCount: null,
|
|
shopCount: null,
|
|
shopCountMode: $this->resolveShopCountModeForMeta(
|
|
commerceIntent: $commerceIntent,
|
|
shopSearchAttempted: $shopSearchAttempted,
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
|
|
),
|
|
sourceLabels: $sources,
|
|
confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('checking_shop_data')
|
|
),
|
|
'meta'
|
|
);
|
|
}
|
|
|
|
yield $this->systemMsg($this->agentRunnerConfig->getRetrieveKnowledgeMessage(), 'think');
|
|
|
|
$knowledgeRetrievalPrompt = $this->buildKnowledgeRetrievalPrompt(
|
|
prompt: $routingPrompt,
|
|
userId: $userId,
|
|
commerceIntent: $commerceIntent
|
|
);
|
|
$usedFollowUpRetrievalContext = $knowledgeRetrievalPrompt !== $routingPrompt;
|
|
|
|
$knowledgeChunks = $this->retriever->retrieve($knowledgeRetrievalPrompt);
|
|
$knowledgeEvidenceState = $this->resolveKnowledgeEvidenceState($routingPrompt, $knowledgeChunks, $urlContent);
|
|
if ($knowledgeChunks !== []) {
|
|
$this->addSource($sources, $this->agentRunnerConfig->getRagKnowledgeSourceLabel());
|
|
}
|
|
|
|
yield $this->systemMsg(
|
|
$this->buildProductionUiMetaMessage(
|
|
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('rag_searched'),
|
|
ragCount: count($knowledgeChunks),
|
|
shopCount: null,
|
|
shopCountMode: $this->resolveShopCountModeForMeta(
|
|
commerceIntent: $commerceIntent,
|
|
shopSearchAttempted: $shopSearchAttempted,
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
|
|
),
|
|
sourceLabels: $sources,
|
|
confidenceLabel: $this->resolveRagEvidenceConfidenceLabel($knowledgeEvidenceState)
|
|
),
|
|
'meta'
|
|
);
|
|
|
|
if ($usedFollowUpRetrievalContext) {
|
|
$this->agentLogger->info('Knowledge retrieval used follow-up context', [
|
|
'userId' => $userId,
|
|
'prompt' => $prompt,
|
|
'routingPrompt' => $routingPrompt,
|
|
'knowledgeRetrievalPrompt' => $knowledgeRetrievalPrompt,
|
|
'commerceIntent' => $commerceIntent,
|
|
]);
|
|
}
|
|
|
|
if ($this->isCommerceIntent($commerceIntent)) {
|
|
yield $this->systemMsg(
|
|
$this->buildProductionUiMetaMessage(
|
|
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_search_preparing'),
|
|
ragCount: count($knowledgeChunks),
|
|
shopCount: null,
|
|
shopCountMode: 'loading',
|
|
sourceLabels: $sources,
|
|
confidenceLabel: $this->resolveRagEvidenceShopCheckConfidenceLabel($knowledgeEvidenceState)
|
|
),
|
|
'meta'
|
|
);
|
|
|
|
yield $this->systemMsg($this->agentRunnerConfig->getOptimizeSearchMessage(), 'think');
|
|
|
|
$commerceHistoryContext = $this->buildCommerceHistoryContext($userId, $requestContextHint);
|
|
$isStandaloneShopQuery = $this->shouldIsolateStandaloneShopQueryFromHistory($originalPrompt);
|
|
$shopQueryHistoryContext = $isStandaloneShopQuery
|
|
? ''
|
|
: $this->resolveShopQueryHistoryContext(
|
|
prompt: $originalPrompt,
|
|
commerceHistoryContext: $commerceHistoryContext
|
|
);
|
|
|
|
if ($shopQueryHistoryContext !== '') {
|
|
$this->addSource($sources, $this->agentRunnerConfig->getConversationHistorySourceLabel());
|
|
}
|
|
|
|
if ($commerceHistoryContext !== '' && $shopQueryHistoryContext === '') {
|
|
$this->agentLogger->info('Ignored commerce history for standalone shop query', [
|
|
'userId' => $userId,
|
|
'prompt' => $prompt,
|
|
'routingPrompt' => $routingPrompt,
|
|
'originalPrompt' => $originalPrompt,
|
|
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
|
'standaloneShopQueryIsolated' => $isStandaloneShopQuery,
|
|
]);
|
|
}
|
|
|
|
if ($isStandaloneShopQuery) {
|
|
$optimizedShopQuery = '';
|
|
$shopSearchQuery = $this->guardFinalStandaloneShopSearchQuery(
|
|
prompt: $originalPrompt,
|
|
shopSearchQuery: $routingPrompt
|
|
);
|
|
|
|
if ($shopSearchQuery === '') {
|
|
$shopSearchQuery = $originalPrompt;
|
|
}
|
|
|
|
$this->agentLogger->info('Using deterministic standalone shop query without LLM optimizer history', [
|
|
'userId' => $userId,
|
|
'prompt' => $prompt,
|
|
'routingPrompt' => $routingPrompt,
|
|
'originalPrompt' => $originalPrompt,
|
|
'shopSearchQuery' => $shopSearchQuery,
|
|
]);
|
|
} else {
|
|
$optimizedShopQuery = yield from $this->buildOptimizedShopQuery(
|
|
$routingPrompt,
|
|
$userId,
|
|
$shopQueryHistoryContext
|
|
);
|
|
|
|
$shopSearchQuery = $this->resolveShopSearchQuery(
|
|
prompt: $originalPrompt,
|
|
optimizedShopQuery: $optimizedShopQuery,
|
|
commerceHistoryContext: $shopQueryHistoryContext,
|
|
userId: $userId,
|
|
currentPromptFallback: $routingPrompt
|
|
);
|
|
}
|
|
|
|
$guardedShopSearchQuery = $this->guardFinalStandaloneShopSearchQuery(
|
|
prompt: $originalPrompt,
|
|
shopSearchQuery: $shopSearchQuery
|
|
);
|
|
|
|
if ($guardedShopSearchQuery !== $shopSearchQuery) {
|
|
$this->agentLogger->info('Replaced standalone shop search query after final guard', [
|
|
'userId' => $userId,
|
|
'prompt' => $prompt,
|
|
'routingPrompt' => $routingPrompt,
|
|
'optimizedShopQuery' => $optimizedShopQuery,
|
|
'unsafeShopSearchQuery' => $shopSearchQuery,
|
|
'guardedShopSearchQuery' => $guardedShopSearchQuery,
|
|
]);
|
|
|
|
$shopSearchQuery = $guardedShopSearchQuery;
|
|
$optimizedShopQuery = '';
|
|
}
|
|
|
|
$referentialAnchoredShopSearchQuery = $this->guardReferentialShopQueryFallbackWithHistoryAnchor(
|
|
prompt: $originalPrompt,
|
|
shopSearchQuery: $shopSearchQuery,
|
|
commerceHistoryContext: $shopQueryHistoryContext
|
|
);
|
|
|
|
if ($referentialAnchoredShopSearchQuery !== $shopSearchQuery) {
|
|
$this->agentLogger->info('Enriched referential shop fallback query with history anchor', [
|
|
'userId' => $userId,
|
|
'prompt' => $prompt,
|
|
'routingPrompt' => $routingPrompt,
|
|
'optimizedShopQuery' => $optimizedShopQuery,
|
|
'shopSearchQuery' => $shopSearchQuery,
|
|
'referentialAnchoredShopSearchQuery' => $referentialAnchoredShopSearchQuery,
|
|
]);
|
|
|
|
$shopSearchQuery = $referentialAnchoredShopSearchQuery;
|
|
$optimizedShopQuery = '';
|
|
}
|
|
|
|
$ragAnchoredShopSearchQuery = $this->enrichShopSearchQueryWithRagAnchor(
|
|
prompt: $originalPrompt,
|
|
shopSearchQuery: $shopSearchQuery,
|
|
knowledgeChunks: $knowledgeChunks
|
|
);
|
|
|
|
if ($ragAnchoredShopSearchQuery !== $shopSearchQuery) {
|
|
$this->agentLogger->info('Enriched shop search query with RAG product anchor', [
|
|
'userId' => $userId,
|
|
'prompt' => $prompt,
|
|
'routingPrompt' => $routingPrompt,
|
|
'optimizedShopQuery' => $optimizedShopQuery,
|
|
'shopSearchQuery' => $shopSearchQuery,
|
|
'ragAnchoredShopSearchQuery' => $ragAnchoredShopSearchQuery,
|
|
]);
|
|
|
|
$shopSearchQuery = $ragAnchoredShopSearchQuery;
|
|
$optimizedShopQuery = '';
|
|
}
|
|
|
|
$positiveFilteredShopSearchQuery = $this->filterShopQueryToPositiveTokens($shopSearchQuery);
|
|
if ($positiveFilteredShopSearchQuery !== $shopSearchQuery) {
|
|
$this->agentLogger->info('Filtered final shop search query to positive product tokens', [
|
|
'userId' => $userId,
|
|
'prompt' => $prompt,
|
|
'routingPrompt' => $routingPrompt,
|
|
'optimizedShopQuery' => $optimizedShopQuery,
|
|
'shopSearchQuery' => $shopSearchQuery,
|
|
'positiveFilteredShopSearchQuery' => $positiveFilteredShopSearchQuery,
|
|
]);
|
|
|
|
$shopSearchQuery = $positiveFilteredShopSearchQuery;
|
|
$optimizedShopQuery = '';
|
|
}
|
|
|
|
if ($shopSearchQuery === '') {
|
|
$this->agentLogger->info('Commerce search skipped because no concrete shop query could be resolved', [
|
|
'userId' => $userId,
|
|
'commerceIntent' => $commerceIntent,
|
|
'prompt' => $prompt,
|
|
'routingPrompt' => $routingPrompt,
|
|
'optimizedShopQuery' => $optimizedShopQuery,
|
|
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
|
|
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
|
'hasRequestContextHint' => trim($requestContextHint) !== '',
|
|
]);
|
|
|
|
$shopSearchSkippedBecauseNoQuery = true;
|
|
$noConcreteShopQueryMessage = $this->agentRunnerConfig->getNoConcreteShopQueryMessage();
|
|
|
|
yield $this->systemMsg(
|
|
$this->buildProductionUiMetaMessage(
|
|
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('more_context_needed'),
|
|
ragCount: count($knowledgeChunks),
|
|
shopCount: null,
|
|
shopCountMode: $this->resolveShopCountModeForMeta(
|
|
commerceIntent: $commerceIntent,
|
|
shopSearchAttempted: $shopSearchAttempted,
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
|
|
),
|
|
sourceLabels: $sources,
|
|
confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('more_context_needed'),
|
|
completed: true
|
|
),
|
|
'meta'
|
|
);
|
|
|
|
yield $this->systemMsg(
|
|
$noConcreteShopQueryMessage,
|
|
'info'
|
|
);
|
|
|
|
$this->contextService->appendHistory(
|
|
$userId,
|
|
$originalPrompt,
|
|
$this->plainTextFromHtml($noConcreteShopQueryMessage)
|
|
);
|
|
|
|
return;
|
|
} else {
|
|
$shopQueryPreview = $this->shopSearchService->buildSearchQueryPreview(
|
|
$shopSearchQuery,
|
|
$commerceIntent,
|
|
$shopQueryHistoryContext
|
|
);
|
|
|
|
$shopSearchDisplayQuery = $shopQueryPreview->searchText !== ''
|
|
? $shopQueryPreview->searchText
|
|
: $shopSearchQuery;
|
|
$shopSearchUsedOptimizedQuery = $optimizedShopQuery !== '';
|
|
|
|
yield $this->systemMsg(
|
|
$this->buildShopSearchMetaMessage(
|
|
query: $shopSearchDisplayQuery,
|
|
commerceIntent: $commerceIntent,
|
|
usedOptimizedQuery: $shopSearchUsedOptimizedQuery,
|
|
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(
|
|
$this->buildProductionUiMetaMessage(
|
|
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_search_running'),
|
|
ragCount: count($knowledgeChunks),
|
|
shopCount: null,
|
|
shopCountMode: 'loading',
|
|
sourceLabels: $sources,
|
|
confidenceLabel: $this->resolveRagEvidenceShopCheckConfidenceLabel($knowledgeEvidenceState)
|
|
),
|
|
'meta'
|
|
);
|
|
|
|
yield $this->systemMsg(
|
|
sprintf($this->agentRunnerConfig->getFetchSearchDataMessageTemplate(), $commerceIntent),
|
|
'think'
|
|
);
|
|
|
|
$shopSearchAttempted = true;
|
|
$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', [
|
|
'userId' => $userId,
|
|
'commerceIntent' => $commerceIntent,
|
|
'shopSearchQuery' => $shopSearchQuery,
|
|
'failureReason' => $primaryShopSearchFailureReason,
|
|
]);
|
|
|
|
$shopUnavailableMessage = $this->buildShopUnavailableMessage($primaryShopSearchFailureReason);
|
|
yield $this->systemMsg(
|
|
$shopUnavailableMessage,
|
|
'err'
|
|
);
|
|
yield $this->systemMsg(
|
|
$this->buildShopSearchMetaMessage(
|
|
query: $shopSearchDisplayQuery !== '' ? $shopSearchDisplayQuery : $shopSearchQuery,
|
|
commerceIntent: $commerceIntent,
|
|
usedOptimizedQuery: $shopSearchUsedOptimizedQuery,
|
|
originalQuery: $shopSearchQuery,
|
|
completed: true,
|
|
unavailable: true
|
|
),
|
|
'meta'
|
|
);
|
|
$historyNotices[] = $this->buildHistoryNotice(
|
|
$this->agentRunnerConfig->getProductionUiText('history_notice_shop_unavailable_title'),
|
|
$primaryShopSearchFailureReason
|
|
);
|
|
|
|
$repairPayload = [
|
|
'results' => $primaryShopResults,
|
|
'attemptedRepair' => false,
|
|
'usedRepair' => false,
|
|
'repairQueries' => [],
|
|
];
|
|
} 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
|
|
);
|
|
}
|
|
}
|
|
|
|
$unguardedShopResults = $repairPayload['results'];
|
|
$shopResults = $this->guardDirectProductShopResults($prompt, $shopSearchQuery, $unguardedShopResults);
|
|
|
|
$directIdentityRepairPayload = $this->repairEmptyDirectProductPrimaryIdentityResults(
|
|
prompt: $prompt,
|
|
userId: $userId,
|
|
commerceIntent: $commerceIntent,
|
|
shopSearchQuery: $shopSearchQuery,
|
|
unguardedShopResults: $unguardedShopResults,
|
|
guardedShopResults: $shopResults
|
|
);
|
|
|
|
if ($directIdentityRepairPayload['results'] !== null) {
|
|
$shopResults = $directIdentityRepairPayload['results'];
|
|
}
|
|
|
|
$shopResults = $this->guardShopResultsByReferencedProductAnchor($shopSearchQuery, $shopResults);
|
|
$shopResults = $this->guardShopResultsByExactRequestedAccessoryCode($prompt, $shopSearchQuery, $shopResults);
|
|
$shopResults = $this->sortShopResultsForLengthRequest($prompt, $shopSearchQuery, $shopResults);
|
|
$attemptedShopRepair = $repairPayload['attemptedRepair'] || $directIdentityRepairPayload['attemptedRepair'];
|
|
$usedShopRepair = $repairPayload['usedRepair'] || $directIdentityRepairPayload['usedRepair'];
|
|
$shopRepairQueries = array_values(array_unique(array_merge(
|
|
$repairPayload['repairQueries'],
|
|
$directIdentityRepairPayload['repairQueries']
|
|
)));
|
|
|
|
if (!$primaryShopSearchHadSystemFailure) {
|
|
yield $this->systemMsg(
|
|
$this->buildShopSearchMetaMessage(
|
|
query: $shopSearchDisplayQuery !== '' ? $shopSearchDisplayQuery : $shopSearchQuery,
|
|
commerceIntent: $commerceIntent,
|
|
usedOptimizedQuery: $shopSearchUsedOptimizedQuery,
|
|
originalQuery: $shopSearchQuery,
|
|
resultCount: count($shopResults),
|
|
completed: true,
|
|
attemptedRepair: $attemptedShopRepair,
|
|
usedRepair: $usedShopRepair
|
|
),
|
|
'meta'
|
|
);
|
|
}
|
|
|
|
if ($shopResults !== []) {
|
|
$this->addSource($sources, $this->agentRunnerConfig->getShopSystemSourceLabel());
|
|
}
|
|
|
|
if ($attemptedShopRepair) {
|
|
$this->addSource($sources, $this->agentRunnerConfig->getExtendedShopSearchSourceLabel());
|
|
}
|
|
|
|
yield $this->systemMsg(
|
|
$this->buildProductionUiMetaMessage(
|
|
stageLabel: $primaryShopSearchHadSystemFailure ? $this->agentRunnerConfig->getProductionUiStageLabel('shop_unavailable') : $this->agentRunnerConfig->getProductionUiStageLabel('shop_completed'),
|
|
ragCount: count($knowledgeChunks),
|
|
shopCount: $primaryShopSearchHadSystemFailure ? null : count($shopResults),
|
|
shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : 'count',
|
|
sourceLabels: $sources,
|
|
confidenceLabel: $this->resolveProductionUiConfidenceLabel(
|
|
hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState),
|
|
isCommerceIntent: true,
|
|
shopSearchAttempted: $shopSearchAttempted,
|
|
hasShopResults: $shopResults !== [],
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
knowledgeEvidenceState: $knowledgeEvidenceState
|
|
)
|
|
),
|
|
'meta'
|
|
);
|
|
}
|
|
|
|
if ($shopResults !== []) {
|
|
$knowledgeChunks = $this->limitKnowledgeChunks($knowledgeChunks, $commerceIntent);
|
|
}
|
|
|
|
yield $this->systemMsg($this->agentRunnerConfig->getAnalyzeAllInformationMessage(), 'think');
|
|
|
|
$finalPrompt = $this->promptBuilder->build(
|
|
prompt: $prompt,
|
|
userId: $userId,
|
|
urlContent: $urlContent,
|
|
knowledgeChunks: $knowledgeChunks,
|
|
shopResults: $shopResults,
|
|
fullContext: $forceFullContext,
|
|
swagFullOutPut: $optimizedShopQuery,
|
|
commerceSearchAttempted: $shopSearchAttempted,
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
knowledgeEvidenceState: $knowledgeEvidenceState
|
|
);
|
|
|
|
if ($this->debug && $this->logPrompt) {
|
|
$this->agentLogger->debug('Final prompt', [
|
|
'userId' => $userId,
|
|
'finalPrompt' => $finalPrompt,
|
|
'optimizedShopQuery' => $optimizedShopQuery,
|
|
'shopSearchQuery' => $shopSearchQuery,
|
|
'knowledgeRetrievalPrompt' => $knowledgeRetrievalPrompt,
|
|
'usedFollowUpRetrievalContext' => $usedFollowUpRetrievalContext,
|
|
'primaryShopResultsCount' => count($primaryShopResults),
|
|
'shopResultsCount' => count($shopResults),
|
|
'attemptedShopRepair' => $attemptedShopRepair,
|
|
'usedShopRepair' => $usedShopRepair,
|
|
'shopRepairQueries' => $shopRepairQueries,
|
|
'shopSearchAttempted' => $shopSearchAttempted,
|
|
]);
|
|
}
|
|
|
|
if ($this->debug && $this->logContext) {
|
|
$this->agentLogger->debug('Conversation context snapshot', [
|
|
'userId' => $userId,
|
|
'context' => $this->contextService->buildUserContext(
|
|
$userId,
|
|
$forceFullContext
|
|
),
|
|
]);
|
|
}
|
|
|
|
yield $this->systemMsg(
|
|
$this->buildProductionUiMetaMessage(
|
|
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('answer_generating'),
|
|
ragCount: count($knowledgeChunks),
|
|
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
|
|
shopCountMode: $this->resolveShopCountModeForMeta(
|
|
commerceIntent: $commerceIntent,
|
|
shopSearchAttempted: $shopSearchAttempted,
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
|
|
),
|
|
sourceLabels: $sources,
|
|
confidenceLabel: $this->resolveProductionUiConfidenceLabel(
|
|
hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState),
|
|
isCommerceIntent: $this->isCommerceIntent($commerceIntent),
|
|
shopSearchAttempted: $shopSearchAttempted,
|
|
hasShopResults: $shopResults !== [],
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
knowledgeEvidenceState: $knowledgeEvidenceState
|
|
)
|
|
),
|
|
'meta'
|
|
);
|
|
|
|
if ($sources !== []) {
|
|
yield $this->emitSources(
|
|
$sources,
|
|
$this->agentRunnerConfig->getUsedSourcesPrefix()
|
|
);
|
|
}
|
|
|
|
$noLlmFallbackAnswer = $this->buildNoLlmFallbackAnswer(
|
|
prompt: $prompt,
|
|
urlContent: $urlContent,
|
|
knowledgeChunks: $knowledgeChunks,
|
|
shopResults: $shopResults,
|
|
commerceIntent: $commerceIntent,
|
|
shopSearchAttempted: $shopSearchAttempted,
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
shopSearchFailureReason: $primaryShopSearchFailureReason ?? null,
|
|
knowledgeEvidenceState: $knowledgeEvidenceState
|
|
);
|
|
|
|
$deterministicDirectShopAnswer = $this->buildDeterministicDirectShopResultAnswer(
|
|
prompt: $prompt,
|
|
shopResults: $shopResults,
|
|
commerceIntent: $commerceIntent,
|
|
shopSearchAttempted: $shopSearchAttempted,
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
shopSearchQuery: $shopSearchQuery
|
|
);
|
|
|
|
if ($deterministicDirectShopAnswer !== '') {
|
|
$fullOutput = $deterministicDirectShopAnswer;
|
|
yield $this->systemMsg($deterministicDirectShopAnswer, 'answer');
|
|
} else {
|
|
$fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer);
|
|
}
|
|
|
|
yield $this->systemMsg(
|
|
$this->buildProductionUiMetaMessage(
|
|
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('completed'),
|
|
ragCount: count($knowledgeChunks),
|
|
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
|
|
shopCountMode: $this->resolveShopCountModeForMeta(
|
|
commerceIntent: $commerceIntent,
|
|
shopSearchAttempted: $shopSearchAttempted,
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
|
|
),
|
|
sourceLabels: $sources,
|
|
confidenceLabel: $this->resolveProductionUiConfidenceLabel(
|
|
hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState),
|
|
isCommerceIntent: $this->isCommerceIntent($commerceIntent),
|
|
shopSearchAttempted: $shopSearchAttempted,
|
|
hasShopResults: $shopResults !== [],
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
knowledgeEvidenceState: $knowledgeEvidenceState
|
|
),
|
|
completed: true
|
|
),
|
|
'meta'
|
|
);
|
|
|
|
$followUpActionsMessage = $this->buildFollowUpActionsMessage(
|
|
isCommerceIntent: $this->isCommerceIntent($commerceIntent),
|
|
hasShopResults: $shopResults !== [],
|
|
hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState),
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
shopResults: $shopResults,
|
|
shopSearchQuery: $shopSearchQuery,
|
|
answerText: $fullOutput
|
|
);
|
|
|
|
if ($followUpActionsMessage !== '') {
|
|
yield $this->systemMsg($followUpActionsMessage, 'meta');
|
|
}
|
|
|
|
/* if ($sources !== []) {
|
|
yield $this->emitSources(
|
|
$sources,
|
|
$this->agentRunnerConfig->getSourcesPrefix()
|
|
);
|
|
}*/
|
|
|
|
if ($this->debug) {
|
|
yield $this->systemMsg($finalPrompt, 'debug');
|
|
}
|
|
|
|
$historyResponse = $this->buildHistoryResponse($fullOutput, $historyNotices);
|
|
|
|
if ($historyResponse !== '') {
|
|
$this->contextService->appendHistory(
|
|
$userId,
|
|
$originalPrompt,
|
|
$historyResponse
|
|
);
|
|
}
|
|
|
|
$this->agentLogger->info('Agent run finished', [
|
|
'userId' => $userId,
|
|
'outputLength' => mb_strlen($fullOutput),
|
|
'contextMode' => $forceFullContext ? 'full' : 'recent',
|
|
'commerceIntent' => $commerceIntent,
|
|
'originalPrompt' => $originalPrompt,
|
|
'effectivePrompt' => $prompt,
|
|
'routingPrompt' => $routingPrompt,
|
|
'promptWasNormalized' => $routingPrompt !== $originalPrompt,
|
|
'primaryShopResultsCount' => count($primaryShopResults),
|
|
'shopResultsCount' => count($shopResults),
|
|
'attemptedShopRepair' => $attemptedShopRepair,
|
|
'usedShopRepair' => $usedShopRepair,
|
|
'shopRepairQueries' => $shopRepairQueries,
|
|
'shopSearchAttempted' => $shopSearchAttempted,
|
|
'primaryShopSearchHadSystemFailure' => $primaryShopSearchHadSystemFailure,
|
|
'primaryShopSearchFailureReason' => $primaryShopSearchFailureReason ?? null,
|
|
'knowledgeChunkCount' => count($knowledgeChunks),
|
|
'knowledgeRetrievalPrompt' => $knowledgeRetrievalPrompt,
|
|
'usedFollowUpRetrievalContext' => $usedFollowUpRetrievalContext,
|
|
'hasUrlContent' => $urlContent !== '',
|
|
'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
|
|
'optimizedShopQuery' => $optimizedShopQuery,
|
|
'shopSearchQuery' => $shopSearchQuery,
|
|
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
|
|
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
|
]);
|
|
} catch (Throwable $e) {
|
|
$this->agentLogger->error('Agent run failed', [
|
|
'userId' => $userId,
|
|
'exception' => $e,
|
|
]);
|
|
|
|
$userErrorMessage = $this->buildUserErrorMessage($e);
|
|
yield $this->systemMsg(
|
|
$this->buildProductionUiMetaMessage(
|
|
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('interrupted'),
|
|
ragCount: count($knowledgeChunks),
|
|
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
|
|
shopCountMode: $this->resolveShopCountModeForMeta(
|
|
commerceIntent: $commerceIntent,
|
|
shopSearchAttempted: $shopSearchAttempted,
|
|
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
|
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
|
|
),
|
|
sourceLabels: $sources,
|
|
confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('interrupted'),
|
|
completed: true
|
|
),
|
|
'meta'
|
|
);
|
|
yield $this->systemMsg($userErrorMessage, 'err');
|
|
|
|
$historyResponse = $this->buildHistoryResponse('', array_merge(
|
|
$historyNotices,
|
|
[$this->buildHistoryNotice($this->agentRunnerConfig->getProductionUiText('history_notice_answer_incomplete_title'), $e->getMessage())]
|
|
));
|
|
|
|
if ($historyResponse !== '') {
|
|
$this->contextService->appendHistory($userId, $originalPrompt, $historyResponse);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return Generator<int, string, mixed, string>
|
|
*/
|
|
private function normalizePromptForRouting(string $prompt, string $userId): Generator
|
|
{
|
|
if (!$this->agentRunnerConfig->isInputNormalizationEnabled()) {
|
|
return $prompt;
|
|
}
|
|
|
|
if ($this->shouldSkipInputNormalization($prompt)) {
|
|
return $prompt;
|
|
}
|
|
|
|
$normalizationPrompt = trim($this->agentRunnerConfig->getInputNormalizationPrompt($prompt));
|
|
if ($normalizationPrompt === '') {
|
|
return $prompt;
|
|
}
|
|
|
|
$candidate = '';
|
|
$lastHeartbeatAt = time();
|
|
$this->thinkSuppressor->reset();
|
|
|
|
try {
|
|
foreach ($this->ollamaClient->stream($normalizationPrompt) as $token) {
|
|
if (!is_string($token)) {
|
|
continue;
|
|
}
|
|
|
|
if (time() - $lastHeartbeatAt >= 2) {
|
|
yield $this->systemMsg($this->agentRunnerConfig->getInputNormalizationHeartbeatMessage(), 'think');
|
|
$lastHeartbeatAt = time();
|
|
}
|
|
|
|
$cleanToken = $this->thinkSuppressor->filter($token);
|
|
if ($cleanToken === '') {
|
|
continue;
|
|
}
|
|
|
|
$candidate .= $cleanToken;
|
|
}
|
|
} catch (Throwable $e) {
|
|
$this->agentLogger->warning('Prompt normalization failed, falling back to fuzzy routing-signal normalization', [
|
|
'userId' => $userId,
|
|
'exception' => $e,
|
|
]);
|
|
|
|
return $this->applyFuzzyRoutingSignalNormalization($prompt, $prompt);
|
|
}
|
|
|
|
$normalized = $this->sanitizeNormalizedPromptForRouting($candidate, $prompt);
|
|
|
|
return $this->applyFuzzyRoutingSignalNormalization($normalized, $prompt);
|
|
}
|
|
|
|
private function shouldSkipInputNormalization(string $prompt): bool
|
|
{
|
|
if (mb_strlen($prompt, 'UTF-8') > $this->agentRunnerConfig->getInputNormalizationMaxInputChars()) {
|
|
return true;
|
|
}
|
|
|
|
foreach ($this->agentRunnerConfig->getInputNormalizationSkipPatterns() as $pattern) {
|
|
if (@preg_match($pattern, $prompt) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function sanitizeNormalizedPromptForRouting(string $candidate, string $originalPrompt): string
|
|
{
|
|
$candidate = trim($candidate);
|
|
if ($candidate === '') {
|
|
return $originalPrompt;
|
|
}
|
|
|
|
$candidate = preg_split('/\R{2,}/u', $candidate, 2)[0] ?? $candidate;
|
|
$candidate = trim($candidate);
|
|
$candidate = preg_replace($this->agentRunnerConfig->getInputNormalizationOutputPrefixPattern(), '', $candidate) ?? $candidate;
|
|
$candidate = trim($candidate, $this->agentRunnerConfig->getOptimizedShopQueryTrimCharacters());
|
|
$candidate = preg_replace('/\s+/u', ' ', $candidate) ?? $candidate;
|
|
$candidate = trim($candidate);
|
|
|
|
if ($candidate === '') {
|
|
return $originalPrompt;
|
|
}
|
|
|
|
if ($this->isInputNormalizationPlaceholderOutput($candidate)) {
|
|
return $originalPrompt;
|
|
}
|
|
|
|
if (mb_strlen($candidate, 'UTF-8') > $this->agentRunnerConfig->getInputNormalizationMaxOutputChars()) {
|
|
return $originalPrompt;
|
|
}
|
|
|
|
if ($this->normalizeRoutingComparisonText($candidate) === $this->normalizeRoutingComparisonText($originalPrompt)) {
|
|
return $originalPrompt;
|
|
}
|
|
|
|
if (!$this->isSafeNormalizedPromptCandidate($candidate, $originalPrompt)) {
|
|
return $originalPrompt;
|
|
}
|
|
|
|
return $candidate;
|
|
}
|
|
|
|
private function applyFuzzyRoutingSignalNormalization(string $candidate, string $originalPrompt): string
|
|
{
|
|
if (!$this->agentRunnerConfig->isInputNormalizationFuzzyRoutingEnabled()) {
|
|
return $candidate;
|
|
}
|
|
|
|
$terms = $this->buildFuzzyRoutingTermIndex();
|
|
if ($terms === []) {
|
|
return $candidate;
|
|
}
|
|
|
|
$minLength = $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMinTokenLength();
|
|
$changed = false;
|
|
|
|
$normalized = preg_replace_callback(
|
|
'/(?<![\p{L}\p{N}])[\p{L}][\p{L}\p{N}\-]{' . max(0, $minLength - 1) . ',}(?![\p{L}\p{N}])/u',
|
|
function (array $matches) use ($terms, &$changed): string {
|
|
$token = (string) ($matches[0] ?? '');
|
|
$replacement = $this->resolveFuzzyRoutingTokenReplacement($token, $terms);
|
|
|
|
if ($replacement === null || $replacement === $token) {
|
|
return $token;
|
|
}
|
|
|
|
$changed = true;
|
|
|
|
return $replacement;
|
|
},
|
|
$candidate
|
|
);
|
|
|
|
if (!is_string($normalized) || !$changed) {
|
|
return $candidate;
|
|
}
|
|
|
|
$normalized = preg_replace('/\s+/u', ' ', trim($normalized)) ?? trim($normalized);
|
|
if ($normalized === '' || $this->normalizeRoutingComparisonText($normalized) === $this->normalizeRoutingComparisonText($candidate)) {
|
|
return $candidate;
|
|
}
|
|
|
|
if (!$this->isSafeNormalizedPromptCandidate($normalized, $originalPrompt)) {
|
|
return $candidate;
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function buildFuzzyRoutingTermIndex(): array
|
|
{
|
|
$terms = [];
|
|
|
|
foreach ($this->agentRunnerConfig->getInputNormalizationFuzzyRoutingTerms() as $term) {
|
|
$term = trim($term);
|
|
if ($term === '') {
|
|
continue;
|
|
}
|
|
|
|
$normalized = $this->normalizeFuzzyRoutingToken($term);
|
|
if ($normalized === '') {
|
|
continue;
|
|
}
|
|
|
|
$terms[$normalized] ??= mb_strtolower($term, 'UTF-8');
|
|
}
|
|
|
|
return $terms;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $terms
|
|
*/
|
|
private function resolveFuzzyRoutingTokenReplacement(string $token, array $terms): ?string
|
|
{
|
|
$normalizedToken = $this->normalizeFuzzyRoutingToken($token);
|
|
if ($normalizedToken === '' || isset($terms[$normalizedToken])) {
|
|
return null;
|
|
}
|
|
|
|
$bestTerm = null;
|
|
$bestDistance = PHP_INT_MAX;
|
|
$ambiguous = false;
|
|
$tokenLength = max(1, strlen($normalizedToken));
|
|
|
|
foreach ($terms as $normalizedTerm => $term) {
|
|
$termLength = strlen($normalizedTerm);
|
|
if (abs($tokenLength - $termLength) > $this->resolveFuzzyRoutingMaxDistance(max($tokenLength, $termLength))) {
|
|
continue;
|
|
}
|
|
|
|
$distance = $this->calculateFuzzyRoutingDistance($normalizedToken, $normalizedTerm);
|
|
$maxLength = max($tokenLength, $termLength);
|
|
$maxDistance = $this->resolveFuzzyRoutingMaxDistance($maxLength);
|
|
if ($distance > $maxDistance) {
|
|
continue;
|
|
}
|
|
|
|
$similarityPercent = (int) round((1 - ($distance / max(1, $maxLength))) * 100);
|
|
if ($similarityPercent < $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMinSimilarityPercent()) {
|
|
continue;
|
|
}
|
|
|
|
if ($distance < $bestDistance) {
|
|
$bestDistance = $distance;
|
|
$bestTerm = $term;
|
|
$ambiguous = false;
|
|
continue;
|
|
}
|
|
|
|
if ($distance === $bestDistance && $term !== $bestTerm) {
|
|
$ambiguous = true;
|
|
}
|
|
}
|
|
|
|
if ($bestTerm === null || $ambiguous) {
|
|
return null;
|
|
}
|
|
|
|
return $bestTerm;
|
|
}
|
|
|
|
private function calculateFuzzyRoutingDistance(string $left, string $right): int
|
|
{
|
|
$leftLength = strlen($left);
|
|
$rightLength = strlen($right);
|
|
|
|
if ($leftLength === 0) {
|
|
return $rightLength;
|
|
}
|
|
|
|
if ($rightLength === 0) {
|
|
return $leftLength;
|
|
}
|
|
|
|
$distance = [];
|
|
for ($i = 0; $i <= $leftLength; $i++) {
|
|
$distance[$i] = [$i];
|
|
}
|
|
|
|
for ($j = 0; $j <= $rightLength; $j++) {
|
|
$distance[0][$j] = $j;
|
|
}
|
|
|
|
for ($i = 1; $i <= $leftLength; $i++) {
|
|
for ($j = 1; $j <= $rightLength; $j++) {
|
|
$cost = $left[$i - 1] === $right[$j - 1] ? 0 : 1;
|
|
$distance[$i][$j] = min(
|
|
$distance[$i - 1][$j] + 1,
|
|
$distance[$i][$j - 1] + 1,
|
|
$distance[$i - 1][$j - 1] + $cost
|
|
);
|
|
|
|
if (
|
|
$i > 1
|
|
&& $j > 1
|
|
&& $left[$i - 1] === $right[$j - 2]
|
|
&& $left[$i - 2] === $right[$j - 1]
|
|
) {
|
|
$distance[$i][$j] = min($distance[$i][$j], $distance[$i - 2][$j - 2] + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $distance[$leftLength][$rightLength];
|
|
}
|
|
|
|
private function resolveFuzzyRoutingMaxDistance(int $tokenLength): int
|
|
{
|
|
if ($tokenLength >= $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingLongTokenLength()) {
|
|
return $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMaxDistanceLong();
|
|
}
|
|
|
|
if ($tokenLength >= $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMediumTokenLength()) {
|
|
return $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMaxDistanceMedium();
|
|
}
|
|
|
|
return $this->agentRunnerConfig->getInputNormalizationFuzzyRoutingMaxDistanceShort();
|
|
}
|
|
|
|
private function normalizeFuzzyRoutingToken(string $token): string
|
|
{
|
|
$token = mb_strtolower(trim($token), 'UTF-8');
|
|
$token = $this->languageCleanupConfig->transliterateToAscii($token);
|
|
$token = preg_replace('/[^a-z0-9]+/u', '', $token) ?? $token;
|
|
|
|
return trim($token);
|
|
}
|
|
|
|
private function isSafeNormalizedPromptCandidate(string $candidate, string $originalPrompt): bool
|
|
{
|
|
$originalLength = max(1, mb_strlen($originalPrompt, 'UTF-8'));
|
|
$candidateLength = mb_strlen($candidate, 'UTF-8');
|
|
$maxLength = (int) ceil($originalLength * ($this->agentRunnerConfig->getInputNormalizationMaxLengthRatioPercent() / 100));
|
|
|
|
if ($candidateLength > $maxLength) {
|
|
return false;
|
|
}
|
|
|
|
$originalTokens = $this->tokenizeInputNormalizationGuardText($originalPrompt);
|
|
$candidateTokens = $this->tokenizeInputNormalizationGuardText($candidate);
|
|
$maxAddedTokens = $this->agentRunnerConfig->getInputNormalizationMaxAddedTokens();
|
|
|
|
if (count($candidateTokens) > count($originalTokens) + $maxAddedTokens) {
|
|
return false;
|
|
}
|
|
|
|
$originalNumbers = $this->extractInputNormalizationNumbers($originalPrompt);
|
|
foreach ($this->extractInputNormalizationNumbers($candidate) as $number) {
|
|
if (!in_array($number, $originalNumbers, true)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function isInputNormalizationPlaceholderOutput(string $candidate): bool
|
|
{
|
|
$normalized = $this->normalizeRoutingComparisonText($candidate);
|
|
|
|
return in_array($normalized, array_map(
|
|
fn (string $placeholder): string => $this->normalizeRoutingComparisonText($placeholder),
|
|
$this->agentRunnerConfig->getInputNormalizationPlaceholderOutputs()
|
|
), true);
|
|
}
|
|
|
|
private function normalizeRoutingComparisonText(string $value): string
|
|
{
|
|
$value = mb_strtolower(trim($value), 'UTF-8');
|
|
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
|
|
|
return trim($value);
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function tokenizeInputNormalizationGuardText(string $value): array
|
|
{
|
|
if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', mb_strtolower($value, 'UTF-8'), $matches) === false) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_filter(
|
|
array_map(static fn(string $token): string => trim($token), $matches[0] ?? []),
|
|
static fn(string $token): bool => $token !== ''
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function extractInputNormalizationNumbers(string $value): array
|
|
{
|
|
if (preg_match_all('/\d+(?:[,.]\d+)?/u', $value, $matches) === false) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_unique(array_map(
|
|
static fn(string $number): string => str_replace(',', '.', $number),
|
|
$matches[0] ?? []
|
|
)));
|
|
}
|
|
|
|
private function detectCommerceIntent(string $prompt): string
|
|
{
|
|
$commerceMeta = $this->commerceIntentLite->detect($prompt);
|
|
|
|
return (string) ($commerceMeta['intent'] ?? CommerceIntentLite::NONE);
|
|
}
|
|
|
|
private function detectCommerceIntentForRouting(
|
|
string $prompt,
|
|
string $userId,
|
|
string $requestContextHint
|
|
): string {
|
|
$commerceIntent = $this->detectCommerceIntent($prompt);
|
|
|
|
if ($this->isCommerceIntent($commerceIntent)) {
|
|
return $commerceIntent;
|
|
}
|
|
|
|
if (!$this->isCommercialTableFollowUpPrompt($prompt)) {
|
|
return $commerceIntent;
|
|
}
|
|
|
|
$this->agentLogger->info('Promoted commercial table follow-up to shop intent', [
|
|
'userId' => $userId,
|
|
'prompt' => $prompt,
|
|
'hasRequestContextHint' => trim($requestContextHint) !== '',
|
|
]);
|
|
|
|
return CommerceIntentLite::PRODUCT_SEARCH;
|
|
}
|
|
|
|
private function isCommerceIntent(string $commerceIntent): bool
|
|
{
|
|
return $commerceIntent === CommerceIntentLite::PRODUCT_SEARCH
|
|
|| $commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH;
|
|
}
|
|
|
|
private function buildKnowledgeRetrievalPrompt(
|
|
string $prompt,
|
|
string $userId,
|
|
string $commerceIntent
|
|
): string {
|
|
if (!$this->shouldUseFollowUpContextForKnowledgeRetrieval($prompt, $commerceIntent)) {
|
|
return $prompt;
|
|
}
|
|
|
|
$history = $this->contextService->buildUserContextWithinBudget($userId, 3000);
|
|
$previousQuestions = $this->extractRecentUserQuestions($history, 2);
|
|
$referenceAnchors = $this->referenceAnchorExtractor->extractLatestAssistantReferenceAnchors($history);
|
|
|
|
if ($previousQuestions === [] && $referenceAnchors === []) {
|
|
return $prompt;
|
|
}
|
|
|
|
$lines = [];
|
|
|
|
foreach ($previousQuestions as $question) {
|
|
$lines[] = $this->renderAgentTemplate($this->agentRunnerConfig->getFollowUpContextPreviousUserQuestionTemplate(), ['question' => $question]);
|
|
}
|
|
|
|
if ($referenceAnchors !== []) {
|
|
$lines[] = 'Vorherige technische Referenzanker (nur zur Referenzauflösung, keine Faktenquelle): '
|
|
. implode(' ', $referenceAnchors);
|
|
}
|
|
|
|
$lines[] = $this->renderAgentTemplate($this->agentRunnerConfig->getFollowUpContextCurrentQuestionTemplate(), ['question' => $prompt]);
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
private function shouldUseFollowUpContextForKnowledgeRetrieval(string $prompt, string $commerceIntent): bool
|
|
{
|
|
if ($this->isCommerceIntent($commerceIntent)) {
|
|
return false;
|
|
}
|
|
|
|
$normalized = $this->normalizeFollowUpText($prompt);
|
|
|
|
if ($normalized === '') {
|
|
return false;
|
|
}
|
|
|
|
if ($this->containsExplicitCommercialFollowUpSignal($normalized)) {
|
|
return false;
|
|
}
|
|
|
|
if (mb_strlen($normalized, 'UTF-8') > 180 && !$this->containsStrongFollowUpReference($normalized)) {
|
|
return false;
|
|
}
|
|
|
|
return $this->containsStrongFollowUpReference($normalized);
|
|
}
|
|
|
|
private function containsStrongFollowUpReference(string $normalized): bool
|
|
{
|
|
foreach ($this->agentRunnerConfig->getFollowUpStrongReferencePatterns() as $pattern) {
|
|
if (preg_match($pattern, $normalized) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function containsExplicitCommercialFollowUpSignal(string $normalized): bool
|
|
{
|
|
foreach ($this->agentRunnerConfig->getFollowUpExplicitCommercialSignalTerms() as $signal) {
|
|
if (str_contains($normalized, mb_strtolower($signal, 'UTF-8'))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function extractRecentUserQuestions(string $history, int $limit): array
|
|
{
|
|
$history = trim($history);
|
|
|
|
if ($history === '' || $limit <= 0) {
|
|
return [];
|
|
}
|
|
|
|
if (preg_match_all($this->agentRunnerConfig->getFollowUpHistoryQuestionPattern(), $history, $matches) === false) {
|
|
return [];
|
|
}
|
|
|
|
$questions = array_values(array_filter(
|
|
array_map(
|
|
fn(string $question): string => $this->sanitizeHistoryQuestion($question),
|
|
$matches[1] ?? []
|
|
),
|
|
static fn(string $question): bool => $question !== ''
|
|
));
|
|
|
|
if ($questions === []) {
|
|
return [];
|
|
}
|
|
|
|
return array_slice($questions, -$limit);
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function extractHistoryTurnsNewestFirst(string $history): array
|
|
{
|
|
$history = trim($history);
|
|
|
|
if ($history === '') {
|
|
return [];
|
|
}
|
|
|
|
$parts = preg_split($this->agentRunnerConfig->getFollowUpHistoryTurnSplitPattern(), $history);
|
|
|
|
if ($parts === false || $parts === []) {
|
|
return [];
|
|
}
|
|
|
|
$turns = array_values(array_filter(
|
|
array_map(static fn(string $part): string => trim($part), $parts),
|
|
static fn(string $part): bool => $part !== ''
|
|
));
|
|
|
|
return array_reverse($turns);
|
|
}
|
|
|
|
private function sanitizeHistoryQuestion(string $question): string
|
|
{
|
|
$question = trim((string) preg_replace('/\s+/u', ' ', $question));
|
|
|
|
if ($question === '') {
|
|
return '';
|
|
}
|
|
|
|
if (mb_strlen($question, 'UTF-8') <= 500) {
|
|
return $question;
|
|
}
|
|
|
|
return rtrim(mb_substr($question, 0, 497, 'UTF-8')) . '...';
|
|
}
|
|
|
|
private function normalizeFollowUpText(string $value): string
|
|
{
|
|
$value = mb_strtolower(trim($value), 'UTF-8');
|
|
$value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value);
|
|
$value = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value;
|
|
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
|
|
|
return trim($value);
|
|
}
|
|
|
|
/**
|
|
* @return Generator<int, string, mixed, string>
|
|
*/
|
|
private function buildOptimizedShopQuery(
|
|
string $prompt,
|
|
string $userId,
|
|
string $commerceHistoryContext = ''
|
|
): Generator {
|
|
$shopPrompt = trim($this->agentRunnerConfig->getShopPrompt(
|
|
$prompt,
|
|
$commerceHistoryContext
|
|
));
|
|
|
|
if ($shopPrompt === '') {
|
|
return '';
|
|
}
|
|
|
|
$optimizedQuery = '';
|
|
$lastHeartbeatAt = time();
|
|
$this->thinkSuppressor->reset();
|
|
|
|
try {
|
|
foreach ($this->ollamaClient->stream($shopPrompt) as $token) {
|
|
if (!is_string($token)) {
|
|
continue;
|
|
}
|
|
|
|
if (time() - $lastHeartbeatAt >= 2) {
|
|
yield $this->systemMsg($this->agentRunnerConfig->getShopQueryOptimizationHeartbeatMessage(), 'think');
|
|
$lastHeartbeatAt = time();
|
|
}
|
|
|
|
$cleanToken = $this->thinkSuppressor->filter($token);
|
|
|
|
if ($cleanToken === '') {
|
|
continue;
|
|
}
|
|
|
|
$optimizedQuery .= $cleanToken;
|
|
}
|
|
} catch (Throwable $e) {
|
|
$this->agentLogger->warning('Shop query optimization failed, falling back to original prompt', [
|
|
'userId' => $userId,
|
|
'exception' => $e,
|
|
]);
|
|
|
|
return '';
|
|
}
|
|
|
|
return $this->sanitizeOptimizedShopQuery($optimizedQuery, $prompt, $commerceHistoryContext);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* results: array,
|
|
* attemptedRepair: bool,
|
|
* usedRepair: bool,
|
|
* repairQueries: string[]
|
|
* }
|
|
*/
|
|
private function repairShopResults(
|
|
string $prompt,
|
|
string $userId,
|
|
string $commerceIntent,
|
|
string $commerceHistoryContext,
|
|
string $primaryQuery,
|
|
array $primaryShopResults,
|
|
array $knowledgeChunks
|
|
): array {
|
|
try {
|
|
return $this->searchRepairService->repair(
|
|
prompt: $prompt,
|
|
commerceIntent: $commerceIntent,
|
|
commerceHistoryContext: $commerceHistoryContext,
|
|
primaryQuery: $primaryQuery,
|
|
primaryShopResults: $primaryShopResults,
|
|
knowledgeChunks: $knowledgeChunks
|
|
);
|
|
} catch (Throwable $e) {
|
|
$this->agentLogger->warning('Shop repair failed, continuing with primary shop results', [
|
|
'userId' => $userId,
|
|
'commerceIntent' => $commerceIntent,
|
|
'primaryQuery' => $primaryQuery,
|
|
'primaryShopResultsCount' => count($primaryShopResults),
|
|
'exception' => $e,
|
|
]);
|
|
|
|
return [
|
|
'results' => $primaryShopResults,
|
|
'attemptedRepair' => false,
|
|
'usedRepair' => false,
|
|
'repairQueries' => [],
|
|
];
|
|
}
|
|
}
|
|
|
|
private function resolveShopQueryHistoryContext(string $prompt, string $commerceHistoryContext): string
|
|
{
|
|
$commerceHistoryContext = trim($commerceHistoryContext);
|
|
|
|
if ($commerceHistoryContext === '') {
|
|
return '';
|
|
}
|
|
|
|
if ($this->shouldUseCommerceHistoryForShopQuery($prompt)) {
|
|
return $commerceHistoryContext;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function shouldUseCommerceHistoryForShopQuery(string $prompt): bool
|
|
{
|
|
$prompt = trim($prompt);
|
|
|
|
if ($prompt === '') {
|
|
return false;
|
|
}
|
|
|
|
if ($this->isCommercialTableFollowUpPrompt($prompt)) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->isMetaOnlyShopQuery($prompt)) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->referenceAnchorExtractor->extractFirstProductModelAnchor($prompt) !== '') {
|
|
return false;
|
|
}
|
|
|
|
$normalizedPrompt = $this->normalizeFollowUpText($prompt);
|
|
|
|
if ($this->containsConfiguredShopQueryAnchorTrigger($normalizedPrompt)) {
|
|
return !$this->containsNumericShopQueryToken($normalizedPrompt);
|
|
}
|
|
|
|
return $this->containsReferentialShopQueryMarker($normalizedPrompt);
|
|
}
|
|
|
|
private function containsNumericShopQueryToken(string $text): bool
|
|
{
|
|
return preg_match('/\d/u', $text) === 1;
|
|
}
|
|
|
|
private function containsReferentialShopQueryMarker(string $text): bool
|
|
{
|
|
$tokens = $this->tokenizeShopQueryCandidate($text);
|
|
|
|
if ($tokens === []) {
|
|
return false;
|
|
}
|
|
|
|
$tokenSet = array_fill_keys($tokens, true);
|
|
|
|
foreach ($this->agentRunnerConfig->getShopQueryContextUsageReferentialTerms() as $term) {
|
|
foreach ($this->tokenizeShopQueryCandidate($term) as $termToken) {
|
|
if (isset($tokenSet[$termToken])) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function shouldIsolateStandaloneShopQueryFromHistory(string $prompt): bool
|
|
{
|
|
$prompt = trim($prompt);
|
|
|
|
if ($prompt === '') {
|
|
return false;
|
|
}
|
|
|
|
if ($this->isCommercialTableFollowUpPrompt($prompt) || $this->isMetaOnlyShopQuery($prompt)) {
|
|
return false;
|
|
}
|
|
|
|
$normalizedPrompt = $this->normalizeFollowUpText($prompt);
|
|
$usesReferenceLanguage = $this->containsReferentialShopQueryMarker($normalizedPrompt)
|
|
|| $this->containsConfiguredShopQueryAnchorTrigger($normalizedPrompt);
|
|
|
|
if (!$usesReferenceLanguage) {
|
|
return true;
|
|
}
|
|
|
|
return $this->hasStandaloneConcreteShopSubject($prompt);
|
|
}
|
|
|
|
private function hasStandaloneConcreteShopSubject(string $prompt): bool
|
|
{
|
|
if ($this->referenceAnchorExtractor->extractFirstProductModelAnchor($prompt) !== '') {
|
|
return true;
|
|
}
|
|
|
|
$contextFallbackQuery = $this->buildContextFallbackShopQuery($prompt);
|
|
$tokens = $this->tokenizeShopQueryCandidate($contextFallbackQuery);
|
|
|
|
if (count($tokens) >= 2) {
|
|
return true;
|
|
}
|
|
|
|
foreach ($tokens as $token) {
|
|
if (preg_match('/\d/u', $token) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function shouldUseDeterministicStandaloneShopQuery(string $prompt, string $shopQueryHistoryContext): bool
|
|
{
|
|
$prompt = trim($prompt);
|
|
|
|
if ($prompt === '') {
|
|
return false;
|
|
}
|
|
|
|
if (trim($shopQueryHistoryContext) !== '') {
|
|
return false;
|
|
}
|
|
|
|
if ($this->isCommercialTableFollowUpPrompt($prompt)) {
|
|
return false;
|
|
}
|
|
|
|
if ($this->isMetaOnlyShopQuery($prompt)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function guardStandaloneOptimizedShopQuery(string $prompt, string $optimizedShopQuery): string
|
|
{
|
|
if ($this->shouldUseCommerceHistoryForShopQuery($prompt)) {
|
|
return $optimizedShopQuery;
|
|
}
|
|
|
|
if ($this->standaloneOptimizedShopQueryIntroducesUnsupportedContext($prompt, $optimizedShopQuery)) {
|
|
$this->agentLogger->info('Ignored optimized shop query because it introduced unsupported standalone context', [
|
|
'prompt' => $prompt,
|
|
'optimizedShopQuery' => $optimizedShopQuery,
|
|
]);
|
|
|
|
return $prompt;
|
|
}
|
|
|
|
if ($this->referenceAnchorExtractor->extractFirstProductModelAnchor($prompt) === '') {
|
|
return $optimizedShopQuery;
|
|
}
|
|
|
|
if (!$this->containsConfiguredShopQueryAnchorTrigger($optimizedShopQuery)) {
|
|
return $optimizedShopQuery;
|
|
}
|
|
|
|
if ($this->containsConfiguredShopQueryAnchorTrigger($prompt)) {
|
|
return $optimizedShopQuery;
|
|
}
|
|
|
|
$this->agentLogger->info('Ignored optimized shop query because it added an unsupported context anchor', [
|
|
'prompt' => $prompt,
|
|
'optimizedShopQuery' => $optimizedShopQuery,
|
|
]);
|
|
|
|
return $prompt;
|
|
}
|
|
|
|
private function guardFinalStandaloneShopSearchQuery(string $prompt, string $shopSearchQuery): string
|
|
{
|
|
$shopSearchQuery = trim($shopSearchQuery);
|
|
|
|
if ($shopSearchQuery === '') {
|
|
return '';
|
|
}
|
|
|
|
$guardedQuery = $this->guardStandaloneOptimizedShopQuery($prompt, $shopSearchQuery);
|
|
$query = $guardedQuery !== $shopSearchQuery
|
|
? $this->preserveCurrentInputShopQueryTerms($prompt, $guardedQuery)
|
|
: $this->preserveCurrentInputShopQueryTerms($prompt, $shopSearchQuery);
|
|
|
|
$query = $this->cleanupDirectProductAttributeShopQuery($prompt, $query);
|
|
|
|
return $this->cleanupShopQueryStopwords($query);
|
|
}
|
|
|
|
private function cleanupShopQueryStopwords(string $shopSearchQuery): string
|
|
{
|
|
$shopSearchQuery = trim($shopSearchQuery);
|
|
|
|
if (
|
|
$shopSearchQuery === ''
|
|
|| !$this->agentRunnerConfig->isShopQueryStopwordCleanupEnabled()
|
|
) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$removeTokens = [];
|
|
foreach ($this->agentRunnerConfig->getShopQueryStopwordCleanupTerms() as $term) {
|
|
foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
|
|
$removeTokens[$token] = true;
|
|
}
|
|
}
|
|
|
|
if ($removeTokens === []) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$kept = [];
|
|
foreach ($this->tokenizeShopQueryCandidate($shopSearchQuery) as $token) {
|
|
if (isset($removeTokens[$token]) || isset($kept[$token])) {
|
|
continue;
|
|
}
|
|
|
|
$kept[$token] = $token;
|
|
}
|
|
|
|
if (count($kept) < max(1, $this->agentRunnerConfig->getShopQueryStopwordCleanupMinTokens())) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$cleaned = implode(' ', array_values($kept));
|
|
|
|
return $cleaned !== '' ? $cleaned : $shopSearchQuery;
|
|
}
|
|
|
|
private function filterShopQueryToPositiveTokens(string $shopSearchQuery): string
|
|
{
|
|
$shopSearchQuery = trim($shopSearchQuery);
|
|
|
|
if (
|
|
$shopSearchQuery === ''
|
|
|| !$this->agentRunnerConfig->isShopQueryPositiveTokenFilterEnabled()
|
|
) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$tokens = $this->tokenizeShopQueryCandidate($shopSearchQuery);
|
|
if ($tokens === []) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$allowedTokens = $this->buildPositiveShopQueryAllowedTokenSet();
|
|
$blockedTokens = $this->buildPositiveShopQueryBlockedTokenSet();
|
|
$codePatterns = $this->agentRunnerConfig->getShopQueryPositiveTokenFilterCodePatterns();
|
|
|
|
if ($allowedTokens === [] && $codePatterns === []) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$kept = [];
|
|
foreach ($tokens as $token) {
|
|
if (isset($blockedTokens[$token]) || isset($kept[$token])) {
|
|
continue;
|
|
}
|
|
|
|
if (isset($allowedTokens[$token]) || $this->matchesAnyConfiguredShopQueryCodePattern($token, $codePatterns)) {
|
|
$kept[$token] = $token;
|
|
}
|
|
}
|
|
|
|
if (count($kept) < max(1, $this->agentRunnerConfig->getShopQueryPositiveTokenFilterMinTokens())) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$filtered = implode(' ', array_values($kept));
|
|
|
|
return $filtered !== '' ? $filtered : $shopSearchQuery;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, true>
|
|
*/
|
|
private function buildPositiveShopQueryAllowedTokenSet(): array
|
|
{
|
|
$terms = $this->agentRunnerConfig->getShopQueryPositiveTokenFilterAllowedTerms();
|
|
|
|
if ($this->agentRunnerConfig->shouldShopQueryPositiveTokenFilterIncludeCurrentInputPreservationTerms()) {
|
|
$terms = $this->mergeUniqueStrings(
|
|
$terms,
|
|
$this->agentRunnerConfig->getShopQueryCurrentInputPreservationTerms()
|
|
);
|
|
}
|
|
|
|
if ($this->agentRunnerConfig->shouldShopQueryPositiveTokenFilterIncludeSemanticShopSearchTokens()) {
|
|
$terms = $this->mergeUniqueStrings(
|
|
$terms,
|
|
$this->agentRunnerConfig->getShopQueryPositiveTokenFilterSemanticShopSearchTokens()
|
|
);
|
|
}
|
|
|
|
if ($this->agentRunnerConfig->shouldShopQueryPositiveTokenFilterIncludeProductRoleTerms()) {
|
|
$terms = $this->mergeUniqueStrings(
|
|
$terms,
|
|
$this->agentRunnerConfig->getShopQueryPositiveTokenFilterProductRoleTerms()
|
|
);
|
|
}
|
|
|
|
$tokens = [];
|
|
foreach ($terms as $term) {
|
|
foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
|
|
$tokens[$token] = true;
|
|
}
|
|
}
|
|
|
|
return $tokens;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, true>
|
|
*/
|
|
private function buildPositiveShopQueryBlockedTokenSet(): array
|
|
{
|
|
$tokens = [];
|
|
|
|
foreach ($this->agentRunnerConfig->getShopQueryPositiveTokenFilterBlockedTerms() as $term) {
|
|
foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
|
|
$tokens[$token] = true;
|
|
}
|
|
}
|
|
|
|
return $tokens;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $patterns
|
|
*/
|
|
private function matchesAnyConfiguredShopQueryCodePattern(string $token, array $patterns): bool
|
|
{
|
|
foreach ($patterns as $pattern) {
|
|
if (@preg_match($pattern, $token) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function cleanupDirectProductAttributeShopQuery(string $prompt, string $shopSearchQuery): string
|
|
{
|
|
$shopSearchQuery = trim($shopSearchQuery);
|
|
|
|
if (
|
|
$shopSearchQuery === ''
|
|
|| !$this->agentRunnerConfig->isShopQueryProductAttributeCleanupEnabled()
|
|
) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$combined = trim($prompt . ' ' . $shopSearchQuery);
|
|
if (!$this->containsAnyShopQueryTerm($combined, $this->agentRunnerConfig->getShopQueryProductAttributeCleanupProductTypeTerms())) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$constraintTokens = $this->extractConfiguredShopQueryConstraintTokens(
|
|
$combined,
|
|
$this->agentRunnerConfig->getShopQueryProductAttributeCleanupComparativeConstraintPatterns()
|
|
);
|
|
|
|
if ($constraintTokens === []) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$removeTokens = array_fill_keys($constraintTokens, true);
|
|
foreach ($this->agentRunnerConfig->getShopQueryProductAttributeCleanupStopTerms() as $term) {
|
|
foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
|
|
$removeTokens[$token] = true;
|
|
}
|
|
}
|
|
|
|
$kept = [];
|
|
foreach ($this->tokenizeShopQueryCandidate($shopSearchQuery) as $token) {
|
|
if (isset($removeTokens[$token]) || isset($kept[$token])) {
|
|
continue;
|
|
}
|
|
|
|
$kept[$token] = $token;
|
|
}
|
|
|
|
if (count($kept) < max(1, $this->agentRunnerConfig->getShopQueryProductAttributeCleanupMinTokens())) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$cleaned = implode(' ', array_values($kept));
|
|
|
|
return $cleaned !== '' ? $cleaned : $shopSearchQuery;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $terms
|
|
*/
|
|
private function containsAnyShopQueryTerm(string $text, array $terms): bool
|
|
{
|
|
$tokens = array_fill_keys($this->tokenizeShopQueryCandidate($text), true);
|
|
|
|
if ($tokens === []) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($terms as $term) {
|
|
$termTokens = $this->tokenizeShopQueryCandidate($term);
|
|
if ($termTokens === []) {
|
|
continue;
|
|
}
|
|
|
|
$matches = true;
|
|
foreach ($termTokens as $termToken) {
|
|
if (!isset($tokens[$termToken])) {
|
|
$matches = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($matches) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $patterns
|
|
* @return string[]
|
|
*/
|
|
private function extractConfiguredShopQueryConstraintTokens(string $text, array $patterns): array
|
|
{
|
|
$tokens = [];
|
|
|
|
foreach ($patterns as $pattern) {
|
|
if (@preg_match_all($pattern, $text, $matches, PREG_SET_ORDER) === false) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($matches as $match) {
|
|
$value = $match['value'] ?? ($match[1] ?? '');
|
|
if (!is_scalar($value)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($this->tokenizeShopQueryCandidate((string) $value) as $token) {
|
|
$tokens[$token] = $token;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values($tokens);
|
|
}
|
|
|
|
private function preserveCurrentInputShopQueryTerms(string $prompt, string $shopSearchQuery): string
|
|
{
|
|
$shopSearchQuery = trim($shopSearchQuery);
|
|
|
|
if ($shopSearchQuery === '' || !$this->agentRunnerConfig->isShopQueryCurrentInputPreservationEnabled()) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$promptTokens = array_fill_keys($this->tokenizeShopQueryCandidate($prompt), true);
|
|
$queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($shopSearchQuery), true);
|
|
|
|
if ($promptTokens === [] || $queryTokens === []) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$appendTokens = [];
|
|
|
|
$preservationTerms = $this->mergeUniqueStrings(
|
|
$this->languageCleanupConfig->getProtectedTerms(),
|
|
$this->agentRunnerConfig->getShopQueryCurrentInputPreservationTerms()
|
|
);
|
|
|
|
foreach ($preservationTerms as $term) {
|
|
$termTokens = $this->tokenizeShopQueryCandidate($term);
|
|
|
|
if ($termTokens === []) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($termTokens as $termToken) {
|
|
if (!isset($promptTokens[$termToken]) || isset($queryTokens[$termToken])) {
|
|
continue;
|
|
}
|
|
|
|
$appendTokens[$termToken] = $termToken;
|
|
$queryTokens[$termToken] = true;
|
|
}
|
|
}
|
|
|
|
if ($appendTokens === []) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
return trim($shopSearchQuery . ' ' . implode(' ', array_values($appendTokens)));
|
|
}
|
|
|
|
/**
|
|
* @param string[] $knowledgeChunks
|
|
*/
|
|
private function enrichShopSearchQueryWithRagAnchor(
|
|
string $prompt,
|
|
string $shopSearchQuery,
|
|
array $knowledgeChunks
|
|
): string {
|
|
$shopSearchQuery = trim($shopSearchQuery);
|
|
|
|
if (
|
|
$shopSearchQuery === ''
|
|
|| $knowledgeChunks === []
|
|
|| !$this->agentRunnerConfig->isShopQueryRagAnchorEnrichmentEnabled()
|
|
) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$focuses = $this->extractShopQueryNumericFocuses($prompt);
|
|
if ($focuses === []) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$anchor = $this->resolveBestRagShopQueryAnchor($knowledgeChunks, $focuses);
|
|
if ($anchor === '') {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($shopSearchQuery), true);
|
|
$anchorTokens = $this->tokenizeShopQueryCandidate($anchor);
|
|
|
|
if ($anchorTokens === []) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$missingAnchorToken = false;
|
|
foreach ($anchorTokens as $anchorToken) {
|
|
if (!isset($queryTokens[$anchorToken])) {
|
|
$missingAnchorToken = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$missingAnchorToken) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$subject = $this->extractRagAnchorSubjectTerms($prompt, $shopSearchQuery);
|
|
$rendered = strtr($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentTemplate(), [
|
|
'{anchor}' => $anchor,
|
|
'{query}' => $shopSearchQuery,
|
|
'{subject}' => $subject,
|
|
]);
|
|
|
|
$enrichedQuery = $this->limitShopQueryTerms(
|
|
$rendered,
|
|
$this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentMaxQueryTerms()
|
|
);
|
|
|
|
return $enrichedQuery !== '' ? $enrichedQuery : $shopSearchQuery;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{value:string, unit:string}>
|
|
*/
|
|
private function extractShopQueryNumericFocuses(string $prompt): array
|
|
{
|
|
$focuses = [];
|
|
$seen = [];
|
|
|
|
foreach ($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentNumericFocusPatterns() as $pattern) {
|
|
if (@preg_match_all($pattern, $prompt, $matches, PREG_SET_ORDER) === false) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($matches as $match) {
|
|
$rawValue = $match['value'] ?? ($match[1] ?? '');
|
|
$rawUnit = $match['unit'] ?? ($match[2] ?? '');
|
|
|
|
if (!is_scalar($rawValue) || !is_scalar($rawUnit)) {
|
|
continue;
|
|
}
|
|
|
|
$value = $this->normalizeShopQueryNumericFocusValue((string) $rawValue);
|
|
$unit = $this->normalizeShopQueryNumericFocusUnit((string) $rawUnit);
|
|
|
|
if ($value === '' || $unit === '') {
|
|
continue;
|
|
}
|
|
|
|
$key = $value . '|' . $unit;
|
|
if (isset($seen[$key])) {
|
|
continue;
|
|
}
|
|
|
|
$seen[$key] = true;
|
|
$focuses[] = [
|
|
'value' => $value,
|
|
'unit' => $unit,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $focuses;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $knowledgeChunks
|
|
* @param array<int, array{value:string, unit:string}> $focuses
|
|
*/
|
|
private function resolveBestRagShopQueryAnchor(array $knowledgeChunks, array $focuses): string
|
|
{
|
|
$bestAnchor = '';
|
|
$bestScore = 0;
|
|
$minScore = $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentMinScore();
|
|
$earlyBonusMax = max(0, $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentEarlyChunkBonusMax());
|
|
|
|
foreach (array_values($knowledgeChunks) as $index => $chunk) {
|
|
$chunk = (string) $chunk;
|
|
$anchor = $this->extractRagProductTitleAnchor($chunk);
|
|
|
|
if ($anchor === '') {
|
|
continue;
|
|
}
|
|
|
|
$score = $this->scoreRagChunkForShopQueryNumericFocus($chunk, $focuses);
|
|
if ($score <= 0) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->ragAnchorMatchesAnyBonusPattern($anchor)) {
|
|
$score += $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentAnchorBonusScore();
|
|
}
|
|
|
|
if ($earlyBonusMax > 0) {
|
|
$score += max(0, $earlyBonusMax - min($earlyBonusMax, $index));
|
|
}
|
|
|
|
if ($score < $minScore || $score <= $bestScore) {
|
|
continue;
|
|
}
|
|
|
|
$bestScore = $score;
|
|
$bestAnchor = $anchor;
|
|
}
|
|
|
|
return $bestAnchor;
|
|
}
|
|
|
|
private function extractRagProductTitleAnchor(string $chunk): string
|
|
{
|
|
foreach ($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentProductTitlePatterns() as $pattern) {
|
|
if (@preg_match($pattern, $chunk, $matches) !== 1) {
|
|
continue;
|
|
}
|
|
|
|
$title = $matches['title'] ?? ($matches[1] ?? '');
|
|
if (!is_scalar($title)) {
|
|
continue;
|
|
}
|
|
|
|
$title = trim(preg_replace('/\s+/u', ' ', str_replace('®', '', (string) $title)) ?? '');
|
|
if ($title !== '') {
|
|
return $title;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{value:string, unit:string}> $focuses
|
|
*/
|
|
private function scoreRagChunkForShopQueryNumericFocus(string $chunk, array $focuses): int
|
|
{
|
|
$normalizedChunk = $this->normalizeShopQueryNumericFocusSearchText($chunk);
|
|
if ($normalizedChunk === '') {
|
|
return 0;
|
|
}
|
|
|
|
$score = 0;
|
|
foreach ($focuses as $focus) {
|
|
$hasValue = $focus['value'] !== '' && str_contains($normalizedChunk, $focus['value']);
|
|
$hasUnit = $focus['unit'] === '' || str_contains($normalizedChunk, $focus['unit']);
|
|
|
|
if ($hasValue && $hasUnit) {
|
|
$score += $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentExactValueUnitScore();
|
|
continue;
|
|
}
|
|
|
|
if ($hasValue) {
|
|
$score += $this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentExactValueScore();
|
|
}
|
|
}
|
|
|
|
return $score;
|
|
}
|
|
|
|
private function ragAnchorMatchesAnyBonusPattern(string $anchor): bool
|
|
{
|
|
foreach ($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentAnchorBonusPatterns() as $pattern) {
|
|
if (@preg_match($pattern, $anchor) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function extractRagAnchorSubjectTerms(string $prompt, string $shopSearchQuery): string
|
|
{
|
|
$promptTokens = array_fill_keys($this->tokenizeShopQueryCandidate($prompt), true);
|
|
$queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($shopSearchQuery), true);
|
|
$subjectTerms = [];
|
|
|
|
foreach ($this->agentRunnerConfig->getShopQueryRagAnchorEnrichmentSubjectTerms() as $term) {
|
|
$termTokens = $this->tokenizeShopQueryCandidate($term);
|
|
if ($termTokens === []) {
|
|
continue;
|
|
}
|
|
|
|
$allPresent = true;
|
|
foreach ($termTokens as $termToken) {
|
|
if (!isset($promptTokens[$termToken])) {
|
|
$allPresent = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$allPresent) {
|
|
continue;
|
|
}
|
|
|
|
$alreadyInQuery = true;
|
|
foreach ($termTokens as $termToken) {
|
|
if (!isset($queryTokens[$termToken])) {
|
|
$alreadyInQuery = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$alreadyInQuery) {
|
|
$subjectTerms[] = $term;
|
|
}
|
|
}
|
|
|
|
return implode(' ', array_values(array_unique($subjectTerms)));
|
|
}
|
|
|
|
private function limitShopQueryTerms(string $query, int $maxTerms): string
|
|
{
|
|
$maxTerms = max(1, $maxTerms);
|
|
$tokens = [];
|
|
|
|
foreach ($this->tokenizeShopQueryCandidate($query) as $token) {
|
|
if (isset($tokens[$token])) {
|
|
continue;
|
|
}
|
|
|
|
$tokens[$token] = $token;
|
|
|
|
if (count($tokens) >= $maxTerms) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return implode(' ', array_values($tokens));
|
|
}
|
|
|
|
private function normalizeShopQueryNumericFocusValue(string $value): string
|
|
{
|
|
$value = $this->normalizeShopQueryNumericFocusSearchText($value);
|
|
$value = preg_replace('/[^0-9,]+/u', '', $value) ?? $value;
|
|
|
|
return trim($value, ',');
|
|
}
|
|
|
|
private function normalizeShopQueryNumericFocusUnit(string $unit): string
|
|
{
|
|
$unit = $this->normalizeShopQueryNumericFocusSearchText($unit);
|
|
$unit = preg_replace('/[^\p{L}]+/u', '', $unit) ?? $unit;
|
|
|
|
return $unit;
|
|
}
|
|
|
|
private function normalizeShopQueryNumericFocusSearchText(string $value): string
|
|
{
|
|
$value = mb_strtolower(trim($value), 'UTF-8');
|
|
$value = $this->languageCleanupConfig->normalizeDashEquivalents($value);
|
|
$value = str_replace('.', ',', $value);
|
|
$value = preg_replace('/\s+/u', '', $value) ?? $value;
|
|
$value = preg_replace('/[^\p{L}\p{N},]+/u', '', $value) ?? $value;
|
|
|
|
return trim($value);
|
|
}
|
|
|
|
private function standaloneOptimizedShopQueryIntroducesUnsupportedContext(
|
|
string $prompt,
|
|
string $optimizedShopQuery
|
|
): bool {
|
|
$promptTokens = array_fill_keys($this->tokenizeShopQueryCandidate($prompt), true);
|
|
$optimizedTokens = $this->tokenizeShopQueryCandidate($optimizedShopQuery);
|
|
|
|
if ($optimizedTokens === [] || $promptTokens === []) {
|
|
return false;
|
|
}
|
|
|
|
$overlap = 0;
|
|
|
|
foreach ($optimizedTokens as $token) {
|
|
if (isset($promptTokens[$token])) {
|
|
$overlap++;
|
|
continue;
|
|
}
|
|
|
|
// 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".
|
|
if (preg_match('/\d/u', $token) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// If the optimized query has no token overlap with the current standalone
|
|
// input, it is not a safe optimization but a context substitution.
|
|
return $overlap === 0 && !$this->isMetaOnlyShopQuery($prompt);
|
|
}
|
|
|
|
private function resolveShopSearchQuery(
|
|
string $prompt,
|
|
string $optimizedShopQuery,
|
|
string $commerceHistoryContext,
|
|
string $userId,
|
|
string $currentPromptFallback = ''
|
|
): string {
|
|
if ($this->isCommercialTableFollowUpPrompt($prompt)) {
|
|
foreach ($this->buildCommercialTableFollowUpContextCandidates($commerceHistoryContext, $userId) as $contextCandidate) {
|
|
$commercialTableContextQuery = $this->extractCommercialTableFollowUpShopQuery($contextCandidate);
|
|
|
|
if ($commercialTableContextQuery !== '' && !$this->isMetaOnlyShopQuery($commercialTableContextQuery)) {
|
|
return $commercialTableContextQuery;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($optimizedShopQuery !== '' && !$this->isMetaOnlyShopQuery($optimizedShopQuery)) {
|
|
return $this->guardStandaloneOptimizedShopQuery($prompt, $optimizedShopQuery);
|
|
}
|
|
|
|
$currentPromptFallback = trim($currentPromptFallback);
|
|
if ($currentPromptFallback !== '' && !$this->isMetaOnlyShopQuery($currentPromptFallback)) {
|
|
return $currentPromptFallback;
|
|
}
|
|
|
|
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 '';
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function buildCommercialTableFollowUpContextCandidates(string $commerceHistoryContext, string $userId): array
|
|
{
|
|
$candidates = [];
|
|
|
|
$commerceHistoryContext = trim($commerceHistoryContext);
|
|
if ($commerceHistoryContext !== '') {
|
|
$candidates[] = $commerceHistoryContext;
|
|
}
|
|
|
|
$extendedHistoryBudget = $this->agentRunnerConfig->getShopQueryContextFallbackHistoryBudgetChars();
|
|
if ($extendedHistoryBudget > mb_strlen($commerceHistoryContext, 'UTF-8')) {
|
|
$extendedHistory = trim($this->contextService->buildUserContextWithinBudget($userId, $extendedHistoryBudget));
|
|
if ($extendedHistory !== '') {
|
|
$candidates[] = $extendedHistory;
|
|
}
|
|
}
|
|
|
|
if ($this->agentRunnerConfig->shouldUseFullHistoryForShopQueryContextFallback()) {
|
|
$fullHistory = trim($this->contextService->buildUserContext($userId, true));
|
|
if ($fullHistory !== '') {
|
|
$candidates[] = $fullHistory;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($candidates));
|
|
}
|
|
|
|
private function extractCommercialTableFollowUpShopQuery(string $commerceHistoryContext): string
|
|
{
|
|
if (!$this->agentRunnerConfig->isCommercialTableFollowUpEnabled()) {
|
|
return '';
|
|
}
|
|
|
|
$fallbackWithoutModel = '';
|
|
|
|
foreach ($this->extractHistoryTurnsNewestFirst($commerceHistoryContext) as $turn) {
|
|
if (!$this->matchesAnyConfiguredPattern(
|
|
$turn,
|
|
$this->agentRunnerConfig->getCommercialTableFollowUpIndicatorMarkerPatterns()
|
|
)) {
|
|
continue;
|
|
}
|
|
|
|
$model = $this->referenceAnchorExtractor->extractFirstProductModelAnchor($turn);
|
|
|
|
if ($model !== '') {
|
|
$query = str_replace(
|
|
'{model}',
|
|
$model,
|
|
$this->agentRunnerConfig->getCommercialTableFollowUpQueryTemplateWithModel()
|
|
);
|
|
|
|
return trim((string) preg_replace('/\s+/u', ' ', $query));
|
|
}
|
|
|
|
$fallbackWithoutModel = trim($this->agentRunnerConfig->getCommercialTableFollowUpQueryTemplateWithoutModel());
|
|
}
|
|
|
|
return $fallbackWithoutModel;
|
|
}
|
|
|
|
private function isCommercialTableFollowUpPrompt(string $prompt): bool
|
|
{
|
|
if (!$this->agentRunnerConfig->isCommercialTableFollowUpEnabled()) {
|
|
return false;
|
|
}
|
|
|
|
return $this->matchesAnyConfiguredPattern(
|
|
$this->normalizeFollowUpText($prompt),
|
|
$this->agentRunnerConfig->getCommercialTableFollowUpPromptPatterns()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param string[] $patterns
|
|
*/
|
|
private function matchesAnyConfiguredPattern(string $text, array $patterns): bool
|
|
{
|
|
foreach ($patterns as $pattern) {
|
|
if (preg_match($pattern, $text) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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 ($this->isUsableContextualShopQuery($contextQuery)) {
|
|
return $contextQuery;
|
|
}
|
|
}
|
|
|
|
return $this->extractContextualShopSearchQueryFromHistoryTurns($commerceHistoryContext);
|
|
}
|
|
|
|
private function extractContextualShopSearchQueryFromHistoryTurns(string $commerceHistoryContext): string
|
|
{
|
|
foreach ($this->extractHistoryTurnsNewestFirst($commerceHistoryContext) as $turn) {
|
|
$question = $this->extractQuestionFromHistoryTurn($turn);
|
|
|
|
if ($question !== '' && !$this->isMetaOnlyShopQuery($question)) {
|
|
$contextQuery = $this->buildContextFallbackShopQuery($question);
|
|
|
|
if ($this->isUsableContextualShopQuery($contextQuery)) {
|
|
return $contextQuery;
|
|
}
|
|
}
|
|
|
|
$modelAnchor = $this->referenceAnchorExtractor->extractFirstProductModelAnchor($turn);
|
|
|
|
if ($modelAnchor !== '' && !$this->isMetaOnlyShopQuery($modelAnchor)) {
|
|
return mb_strtolower($modelAnchor, 'UTF-8');
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function extractQuestionFromHistoryTurn(string $turn): string
|
|
{
|
|
if (preg_match($this->agentRunnerConfig->getFollowUpHistoryQuestionPattern(), $turn, $matches) !== 1) {
|
|
return '';
|
|
}
|
|
|
|
return $this->sanitizeHistoryQuestion((string) ($matches[1] ?? ''));
|
|
}
|
|
|
|
private function isUsableContextualShopQuery(string $query): bool
|
|
{
|
|
$query = trim($query);
|
|
|
|
if ($query === '' || $this->isMetaOnlyShopQuery($query)) {
|
|
return false;
|
|
}
|
|
|
|
return $this->tokenizeShopQueryCandidate($query) !== [];
|
|
}
|
|
|
|
private function buildContextFallbackShopQuery(string $question): string
|
|
{
|
|
$tokens = $this->tokenizeShopQueryCandidate($question);
|
|
|
|
if ($tokens === []) {
|
|
return '';
|
|
}
|
|
|
|
$filterTerms = [];
|
|
|
|
foreach ($this->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 getShopQueryContextFallbackFilterTerms(): array
|
|
{
|
|
$profileName = $this->agentRunnerConfig->getShopQueryContextFallbackCleanupProfile();
|
|
|
|
return $this->mergeUniqueStrings(
|
|
$this->mergeUniqueStrings(
|
|
$this->languageCleanupConfig->getStopWordsForProfile($profileName),
|
|
$this->languageCleanupConfig->getPhrasesForProfile($profileName)
|
|
),
|
|
$this->mergeUniqueStrings(
|
|
$this->languageCleanupConfig->getMetaTermsForProfile($profileName),
|
|
$this->mergeUniqueStrings(
|
|
$this->agentRunnerConfig->getShopQueryMetaOnlyTerms(),
|
|
$this->agentRunnerConfig->getShopQueryContextFallbackFilterTerms()
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
/** @return string[] */
|
|
private function getShopQueryMetaGuardTerms(): array
|
|
{
|
|
$profileName = $this->agentRunnerConfig->getShopQueryContextFallbackCleanupProfile();
|
|
|
|
return $this->mergeUniqueStrings(
|
|
$this->mergeUniqueStrings(
|
|
$this->languageCleanupConfig->getStopWordsForProfile($profileName),
|
|
$this->languageCleanupConfig->getPhrasesForProfile($profileName)
|
|
),
|
|
$this->mergeUniqueStrings(
|
|
$this->languageCleanupConfig->getMetaTermsForProfile($profileName),
|
|
$this->agentRunnerConfig->getShopQueryMetaOnlyTerms()
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function tokenizeShopQueryCandidate(string $value): array
|
|
{
|
|
$value = mb_strtolower(trim($value), 'UTF-8');
|
|
$value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value);
|
|
|
|
if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', $value, $matches) === false) {
|
|
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->getShopQueryMetaGuardTerms() 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 = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($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,
|
|
string $userId,
|
|
string $commerceHistoryContext = ''
|
|
): array {
|
|
try {
|
|
return $this->shopSearchService->search(
|
|
$query,
|
|
$commerceIntent,
|
|
$commerceHistoryContext
|
|
);
|
|
} catch (Throwable $e) {
|
|
$this->agentLogger->warning('Shop search failed, continuing without shop results', [
|
|
'userId' => $userId,
|
|
'commerceIntent' => $commerceIntent,
|
|
'query' => $query,
|
|
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
|
|
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
|
'exception' => $e,
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private function buildCommerceHistoryContext(string $userId, string $requestContextHint = ''): string
|
|
{
|
|
$history = $this->contextService->buildUserContextWithinBudget(
|
|
$userId,
|
|
$this->agentRunnerConfig->getCommerceHistoryBudgetChars()
|
|
);
|
|
|
|
$requestContextHint = $this->sanitizeRequestContextHintForCommerce($requestContextHint);
|
|
|
|
if ($requestContextHint === '') {
|
|
return $history;
|
|
}
|
|
|
|
if ($history === '') {
|
|
return $requestContextHint;
|
|
}
|
|
|
|
return trim($history) . "\n\n" . $requestContextHint;
|
|
}
|
|
|
|
private function sanitizeRequestContextHintForCommerce(string $requestContextHint): string
|
|
{
|
|
$requestContextHint = str_replace(["\r\n", "\r"], "\n", $requestContextHint);
|
|
$requestContextHint = preg_replace('/[\t ]+/u', ' ', $requestContextHint) ?? $requestContextHint;
|
|
$requestContextHint = preg_replace('/\n{3,}/u', "\n\n", $requestContextHint) ?? $requestContextHint;
|
|
$requestContextHint = trim($requestContextHint);
|
|
|
|
if ($requestContextHint === '') {
|
|
return '';
|
|
}
|
|
|
|
if (mb_strlen($requestContextHint, 'UTF-8') > 4000) {
|
|
$requestContextHint = mb_substr($requestContextHint, 0, 4000, 'UTF-8');
|
|
}
|
|
|
|
return trim($requestContextHint);
|
|
}
|
|
|
|
private function limitKnowledgeChunks(array $knowledgeChunks, string $commerceIntent): array
|
|
{
|
|
return match ($commerceIntent) {
|
|
CommerceIntentLite::PRODUCT_SEARCH => array_slice(
|
|
$knowledgeChunks,
|
|
0,
|
|
$this->agentRunnerConfig->getProductSearchKnowledgeChunkLimit()
|
|
),
|
|
CommerceIntentLite::ADVISORY_PRODUCT_SEARCH => array_slice(
|
|
$knowledgeChunks,
|
|
0,
|
|
$this->agentRunnerConfig->getAdvisoryProductSearchKnowledgeChunkLimit()
|
|
),
|
|
default => $knowledgeChunks,
|
|
};
|
|
}
|
|
|
|
private function sanitizeOptimizedShopQuery(
|
|
string $query,
|
|
string $sourcePrompt = '',
|
|
string $commerceHistoryContext = ''
|
|
): string {
|
|
$query = trim($query);
|
|
|
|
if ($query === '') {
|
|
return '';
|
|
}
|
|
|
|
$query = preg_split('/\R+/u', $query, 2)[0] ?? $query;
|
|
$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 guardReferentialShopQueryFallbackWithHistoryAnchor(
|
|
string $prompt,
|
|
string $shopSearchQuery,
|
|
string $commerceHistoryContext
|
|
): string {
|
|
if (!$this->agentRunnerConfig->isShopQueryContextAnchorEnrichmentEnabled()) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
if (trim($commerceHistoryContext) === '') {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
if (!$this->shouldUseCommerceHistoryForShopQuery($prompt)) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$combined = trim($shopSearchQuery . ' ' . $prompt);
|
|
if (!$this->containsConfiguredShopQueryAnchorTrigger($combined)) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$anchor = $this->normalizeShopQueryAnchor(
|
|
$this->extractLatestConfiguredShopQueryContextAnchor($commerceHistoryContext)
|
|
);
|
|
|
|
if ($anchor === '' || $this->queryAlreadyContainsAllAnchorTokens($shopSearchQuery, $anchor)) {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$referentialQuery = $this->extractReferentialShopQueryTriggerTerms($combined);
|
|
if ($referentialQuery === '') {
|
|
return $shopSearchQuery;
|
|
}
|
|
|
|
$template = $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTemplate();
|
|
$enriched = $this->renderAgentTemplate($template, [
|
|
'anchor' => $anchor,
|
|
'query' => $referentialQuery,
|
|
]);
|
|
$enriched = preg_replace('/\s+/u', ' ', $enriched) ?? $enriched;
|
|
$enriched = trim($enriched);
|
|
|
|
return $enriched !== '' ? $enriched : $shopSearchQuery;
|
|
}
|
|
|
|
private function extractReferentialShopQueryTriggerTerms(string $text): string
|
|
{
|
|
$tokens = $this->tokenizeShopQueryCandidate($text);
|
|
|
|
if ($tokens === []) {
|
|
return '';
|
|
}
|
|
|
|
$triggerTokens = $this->buildShopQueryTokenSet(
|
|
$this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTriggerTerms()
|
|
);
|
|
|
|
if ($triggerTokens === []) {
|
|
return '';
|
|
}
|
|
|
|
$hasTrigger = false;
|
|
foreach ($tokens as $token) {
|
|
if (isset($triggerTokens[$token])) {
|
|
$hasTrigger = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$hasTrigger) {
|
|
return '';
|
|
}
|
|
|
|
$queryTokens = $this->buildShopQueryTokenSet(
|
|
$this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentQueryTerms()
|
|
);
|
|
if ($queryTokens === []) {
|
|
$queryTokens = $triggerTokens;
|
|
}
|
|
|
|
$noiseTokens = $this->buildShopQueryTokenSet(
|
|
$this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentQueryNoiseTerms()
|
|
);
|
|
|
|
$out = [];
|
|
foreach ($tokens as $token) {
|
|
if (!isset($queryTokens[$token]) || isset($noiseTokens[$token]) || isset($out[$token])) {
|
|
continue;
|
|
}
|
|
|
|
$out[$token] = $token;
|
|
}
|
|
|
|
return implode(' ', array_values($out));
|
|
}
|
|
|
|
/**
|
|
* @param string[] $terms
|
|
* @return array<string, true>
|
|
*/
|
|
private function buildShopQueryTokenSet(array $terms): array
|
|
{
|
|
$tokens = [];
|
|
|
|
foreach ($terms as $term) {
|
|
foreach ($this->tokenizeShopQueryCandidate($term) as $termToken) {
|
|
$tokens[$termToken] = true;
|
|
}
|
|
}
|
|
|
|
return $tokens;
|
|
}
|
|
|
|
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 = $this->renderAgentTemplate($template, [
|
|
'anchor' => $anchor,
|
|
'query' => $query,
|
|
]);
|
|
$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
|
|
{
|
|
foreach ($this->extractHistoryTurnsNewestFirst($commerceHistoryContext) as $turn) {
|
|
if (!$this->containsConfiguredShopQueryAnchorTrigger($turn)) {
|
|
continue;
|
|
}
|
|
|
|
$modelAnchor = $this->referenceAnchorExtractor->extractFirstProductModelAnchor($turn);
|
|
$turnAnchor = $this->extractLatestConfiguredShopQueryPatternAnchor($turn);
|
|
|
|
if ($modelAnchor !== '') {
|
|
return $this->buildModelQualifiedShopQueryAnchor($modelAnchor, $turnAnchor);
|
|
}
|
|
|
|
if ($turnAnchor !== '') {
|
|
return $turnAnchor;
|
|
}
|
|
}
|
|
|
|
return $this->extractLatestConfiguredShopQueryPatternAnchor($commerceHistoryContext);
|
|
}
|
|
|
|
private function extractLatestConfiguredShopQueryPatternAnchor(string $text): string
|
|
{
|
|
$latest = '';
|
|
|
|
foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentPatterns() as $pattern) {
|
|
if (@preg_match_all($pattern, $text, $matches, PREG_SET_ORDER) === false) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($matches as $match) {
|
|
$candidate = trim((string) ($match[0] ?? ''));
|
|
if ($candidate !== '') {
|
|
$latest = $candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $latest;
|
|
}
|
|
|
|
private function buildModelQualifiedShopQueryAnchor(string $modelAnchor, string $detailAnchor): string
|
|
{
|
|
$modelAnchor = trim($modelAnchor);
|
|
if ($modelAnchor === '') {
|
|
return trim($detailAnchor);
|
|
}
|
|
|
|
$detailTokens = $this->extractShopQueryDetailAnchorTokens($detailAnchor, $modelAnchor);
|
|
if ($detailTokens === []) {
|
|
return $modelAnchor;
|
|
}
|
|
|
|
return trim($modelAnchor . ' ' . implode(' ', $detailTokens));
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function extractShopQueryDetailAnchorTokens(string $detailAnchor, string $modelAnchor): array
|
|
{
|
|
$tokens = $this->tokenizeShopQueryCandidate($detailAnchor);
|
|
if ($tokens === []) {
|
|
return [];
|
|
}
|
|
|
|
$modelTokens = array_fill_keys($this->tokenizeShopQueryCandidate($modelAnchor), true);
|
|
$queryTokens = $this->buildShopQueryTokenSet(
|
|
$this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentQueryTerms()
|
|
);
|
|
$noiseTokens = $this->buildShopQueryTokenSet(
|
|
$this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentQueryNoiseTerms()
|
|
);
|
|
|
|
$out = [];
|
|
foreach ($tokens as $token) {
|
|
if (isset($modelTokens[$token]) || isset($queryTokens[$token]) || isset($noiseTokens[$token]) || isset($out[$token])) {
|
|
continue;
|
|
}
|
|
|
|
$out[$token] = $token;
|
|
}
|
|
|
|
return array_values($out);
|
|
}
|
|
|
|
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>
|
|
*/
|
|
private function streamFinalAnswer(string $finalPrompt, string $noLlmFallbackAnswer = ''): Generator
|
|
{
|
|
$fullOutput = '';
|
|
$thinkingNoticeShown = false;
|
|
$stoppedByFinalAnswerGuard = false;
|
|
$chunker = new StreamChunker();
|
|
|
|
$this->thinkSuppressor->reset();
|
|
|
|
yield $this->systemMsg($this->agentRunnerConfig->getThinkingWhileStreamingMessage(), 'think');
|
|
$thinkingNoticeShown = true;
|
|
|
|
try {
|
|
foreach ($this->ollamaClient->stream($finalPrompt) as $token) {
|
|
if (!is_string($token)) {
|
|
continue;
|
|
}
|
|
|
|
$cleanToken = $this->thinkSuppressor->filter($token);
|
|
|
|
if ($cleanToken === '') {
|
|
if (!$thinkingNoticeShown) {
|
|
yield $this->systemMsg($this->agentRunnerConfig->getThinkingWhileStreamingMessage(), 'think');
|
|
$thinkingNoticeShown = true;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
$guardReason = null;
|
|
$cleanToken = $this->guardFinalAnswerToken($fullOutput, $cleanToken, $guardReason);
|
|
|
|
if ($cleanToken !== '') {
|
|
$fullOutput .= $cleanToken;
|
|
|
|
$chunk = $chunker->push($cleanToken);
|
|
if ($chunk !== null) {
|
|
yield $this->systemMsg($chunk, 'answer');
|
|
}
|
|
}
|
|
|
|
if ($guardReason !== null) {
|
|
$stoppedByFinalAnswerGuard = true;
|
|
|
|
$finalChunk = $chunker->flush();
|
|
if ($finalChunk !== null) {
|
|
yield $this->systemMsg($finalChunk, 'answer');
|
|
}
|
|
|
|
$guardMessage = $this->agentRunnerConfig->getFinalAnswerGuardTruncationMessage();
|
|
$fullOutput .= $guardMessage;
|
|
yield $this->systemMsg($guardMessage, 'answer');
|
|
|
|
$this->agentLogger->warning('Final answer guard stopped LLM output', [
|
|
'reason' => $guardReason,
|
|
'outputLength' => mb_strlen($fullOutput, 'UTF-8'),
|
|
]);
|
|
|
|
break;
|
|
}
|
|
}
|
|
} catch (Throwable $e) {
|
|
$noLlmFallbackAnswer = trim($noLlmFallbackAnswer);
|
|
|
|
if ($noLlmFallbackAnswer === '' || $fullOutput !== '') {
|
|
throw $e;
|
|
}
|
|
|
|
$this->agentLogger->warning('LLM stream failed before answer tokens, using deterministic no-LLM fallback answer', [
|
|
'exception' => $e,
|
|
]);
|
|
|
|
$fullOutput = $noLlmFallbackAnswer;
|
|
yield $this->systemMsg($noLlmFallbackAnswer, 'answer');
|
|
|
|
return $fullOutput;
|
|
}
|
|
|
|
if ($stoppedByFinalAnswerGuard) {
|
|
return $fullOutput;
|
|
}
|
|
|
|
$finalChunk = $chunker->flush();
|
|
if ($finalChunk !== null) {
|
|
yield $this->systemMsg($finalChunk, 'answer');
|
|
} elseif ($fullOutput === '') {
|
|
$noLlmFallbackAnswer = trim($noLlmFallbackAnswer);
|
|
|
|
if ($noLlmFallbackAnswer !== '') {
|
|
$fullOutput = $noLlmFallbackAnswer;
|
|
yield $this->systemMsg($noLlmFallbackAnswer, 'answer');
|
|
} else {
|
|
yield $this->systemMsg($this->agentRunnerConfig->getNoLlmDataReceivedMessage(), 'err');
|
|
}
|
|
}
|
|
|
|
return $fullOutput;
|
|
}
|
|
|
|
private function guardFinalAnswerToken(string $currentOutput, string $nextToken, ?string &$reason): string
|
|
{
|
|
$reason = null;
|
|
|
|
if (!$this->agentRunnerConfig->isFinalAnswerGuardEnabled()) {
|
|
return $nextToken;
|
|
}
|
|
|
|
$maxOutputChars = max(1000, $this->agentRunnerConfig->getFinalAnswerGuardMaxOutputChars());
|
|
$currentChars = mb_strlen($currentOutput, 'UTF-8');
|
|
$nextChars = mb_strlen($nextToken, 'UTF-8');
|
|
|
|
if (($currentChars + $nextChars) > $maxOutputChars) {
|
|
$reason = 'max_output_chars';
|
|
$remainingChars = max(0, $maxOutputChars - $currentChars);
|
|
|
|
return $remainingChars > 0 ? mb_substr($nextToken, 0, $remainingChars, 'UTF-8') : '';
|
|
}
|
|
|
|
$candidate = $currentOutput . $nextToken;
|
|
$cutoffBytes = $this->detectRepeatedFinalAnswerLineCutoff($candidate);
|
|
|
|
if ($cutoffBytes === null) {
|
|
return $nextToken;
|
|
}
|
|
|
|
$reason = 'repeated_line';
|
|
$currentBytes = strlen($currentOutput);
|
|
|
|
if ($cutoffBytes <= $currentBytes) {
|
|
return '';
|
|
}
|
|
|
|
return mb_strcut($nextToken, 0, $cutoffBytes - $currentBytes, 'UTF-8');
|
|
}
|
|
|
|
private function detectRepeatedFinalAnswerLineCutoff(string $text): ?int
|
|
{
|
|
if (!$this->agentRunnerConfig->isFinalAnswerRepeatedLineGuardEnabled()) {
|
|
return null;
|
|
}
|
|
|
|
if (mb_strlen($text, 'UTF-8') < max(0, $this->agentRunnerConfig->getFinalAnswerRepeatedLineMinOutputChars())) {
|
|
return null;
|
|
}
|
|
|
|
if (preg_match_all('/[^\r\n]+/u', $text, $matches, PREG_OFFSET_CAPTURE) === false) {
|
|
return null;
|
|
}
|
|
|
|
$lines = $matches[0] ?? [];
|
|
$window = max(10, $this->agentRunnerConfig->getFinalAnswerRepeatedLineTrailingWindowLines());
|
|
if (count($lines) > $window) {
|
|
$lines = array_slice($lines, -$window);
|
|
}
|
|
|
|
$counts = [];
|
|
$maxRepetitions = max(1, $this->agentRunnerConfig->getFinalAnswerRepeatedLineMaxRepetitions());
|
|
|
|
foreach ($lines as $lineMatch) {
|
|
$line = (string) ($lineMatch[0] ?? '');
|
|
$offset = (int) ($lineMatch[1] ?? 0);
|
|
$normalizedLine = $this->normalizeFinalAnswerLineForRepetitionGuard($line);
|
|
|
|
if ($normalizedLine === '') {
|
|
continue;
|
|
}
|
|
|
|
$counts[$normalizedLine] = ($counts[$normalizedLine] ?? 0) + 1;
|
|
|
|
if ($counts[$normalizedLine] > $maxRepetitions) {
|
|
return $offset;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function normalizeFinalAnswerLineForRepetitionGuard(string $line): string
|
|
{
|
|
$line = html_entity_decode(strip_tags($line), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$line = preg_replace('/^\s*(?:[-*•]+|\d+[.)])\s*/u', '', $line) ?? $line;
|
|
$line = preg_replace('/\s+/u', ' ', $line) ?? $line;
|
|
$line = trim($line, " \t\n\r\0\x0B:;.-");
|
|
|
|
if ($line === '') {
|
|
return '';
|
|
}
|
|
|
|
foreach ($this->agentRunnerConfig->getFinalAnswerRepeatedLineIgnorePatterns() as $pattern) {
|
|
try {
|
|
if (@preg_match($pattern, $line) === 1) {
|
|
return '';
|
|
}
|
|
} catch (Throwable) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (mb_strlen($line, 'UTF-8') < max(1, $this->agentRunnerConfig->getFinalAnswerRepeatedLineMinLineChars())) {
|
|
return '';
|
|
}
|
|
|
|
return mb_strtolower($line, 'UTF-8');
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function guardDirectProductShopResults(string $prompt, string $shopSearchQuery, array $shopResults): array
|
|
{
|
|
if (
|
|
$shopResults === []
|
|
|| !$this->agentRunnerConfig->isDirectShopResultGuardEnabled()
|
|
) {
|
|
return $shopResults;
|
|
}
|
|
|
|
$requestedTerms = $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery);
|
|
if ($requestedTerms === []) {
|
|
return $shopResults;
|
|
}
|
|
|
|
$primaryMatches = [];
|
|
$corpusMatches = [];
|
|
|
|
foreach ($shopResults as $product) {
|
|
if (!$product instanceof ShopProductResult) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->shopProductPrimaryIdentityMatchesAnyDirectProductTerm($product, $requestedTerms)) {
|
|
$primaryMatches[] = $product;
|
|
continue;
|
|
}
|
|
|
|
if ($this->shopProductMatchesAnyDirectProductTerm($product, $requestedTerms)) {
|
|
$corpusMatches[] = $product;
|
|
}
|
|
}
|
|
|
|
if ($this->agentRunnerConfig->shouldPreferDirectShopResultGuardPrimaryIdentityMatches()) {
|
|
return $primaryMatches;
|
|
}
|
|
|
|
return array_values(array_merge($primaryMatches, $corpusMatches));
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $unguardedShopResults
|
|
* @param ShopProductResult[] $guardedShopResults
|
|
* @return array{results: array|null, attemptedRepair: bool, usedRepair: bool, repairQueries: string[]}
|
|
*/
|
|
private function repairEmptyDirectProductPrimaryIdentityResults(
|
|
string $prompt,
|
|
string $userId,
|
|
string $commerceIntent,
|
|
string $shopSearchQuery,
|
|
array $unguardedShopResults,
|
|
array $guardedShopResults
|
|
): array {
|
|
$emptyResult = [
|
|
'results' => null,
|
|
'attemptedRepair' => false,
|
|
'usedRepair' => false,
|
|
'repairQueries' => [],
|
|
];
|
|
|
|
if (
|
|
$guardedShopResults !== []
|
|
|| $unguardedShopResults === []
|
|
|| !$this->agentRunnerConfig->isDirectShopResultGuardPrimaryIdentityRepairEnabled()
|
|
) {
|
|
return $emptyResult;
|
|
}
|
|
|
|
$requestedTerms = $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery);
|
|
if ($requestedTerms === []) {
|
|
return $emptyResult;
|
|
}
|
|
|
|
$repairQuery = $this->buildDirectProductPrimaryIdentityRepairQuery(
|
|
shopSearchQuery: $shopSearchQuery,
|
|
requestedTerms: $requestedTerms
|
|
);
|
|
|
|
if ($repairQuery === '' || $this->normalizeShopQueryForComparison($repairQuery) === $this->normalizeShopQueryForComparison($shopSearchQuery)) {
|
|
return $emptyResult;
|
|
}
|
|
|
|
$this->agentLogger->info('Direct product primary identity guard retrying with cleaned repair query', [
|
|
'userId' => $userId,
|
|
'commerceIntent' => $commerceIntent,
|
|
'prompt' => $prompt,
|
|
'shopSearchQuery' => $shopSearchQuery,
|
|
'repairQuery' => $repairQuery,
|
|
'unguardedShopResultsCount' => count($unguardedShopResults),
|
|
'requestedTerms' => $requestedTerms,
|
|
]);
|
|
|
|
$repairResults = $this->searchShop(
|
|
$repairQuery,
|
|
$commerceIntent,
|
|
$userId,
|
|
''
|
|
);
|
|
|
|
if ($this->shopSearchService->hadLastSearchSystemFailure()) {
|
|
return [
|
|
'results' => null,
|
|
'attemptedRepair' => true,
|
|
'usedRepair' => false,
|
|
'repairQueries' => [$repairQuery],
|
|
];
|
|
}
|
|
|
|
$guardedRepairResults = $this->guardDirectProductShopResults($prompt, $repairQuery, $repairResults);
|
|
|
|
if ($guardedRepairResults === []) {
|
|
$this->agentLogger->info('Direct product primary identity repair finished without matching products', [
|
|
'userId' => $userId,
|
|
'commerceIntent' => $commerceIntent,
|
|
'prompt' => $prompt,
|
|
'shopSearchQuery' => $shopSearchQuery,
|
|
'repairQuery' => $repairQuery,
|
|
'repairResultsCount' => count($repairResults),
|
|
]);
|
|
|
|
return [
|
|
'results' => null,
|
|
'attemptedRepair' => true,
|
|
'usedRepair' => false,
|
|
'repairQueries' => [$repairQuery],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'results' => $guardedRepairResults,
|
|
'attemptedRepair' => true,
|
|
'usedRepair' => true,
|
|
'repairQueries' => [$repairQuery],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param string[] $requestedTerms
|
|
*/
|
|
private function buildDirectProductPrimaryIdentityRepairQuery(string $shopSearchQuery, array $requestedTerms): string
|
|
{
|
|
$tokens = $this->tokenizeShopQueryCandidate($shopSearchQuery);
|
|
if ($tokens === []) {
|
|
return '';
|
|
}
|
|
|
|
$stopTokens = [];
|
|
foreach ($this->agentRunnerConfig->getDirectShopResultGuardPrimaryIdentityRepairStopTerms() as $term) {
|
|
foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
|
|
$stopTokens[$token] = true;
|
|
}
|
|
}
|
|
|
|
$requestedTokens = [];
|
|
foreach ($requestedTerms as $term) {
|
|
foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
|
|
$requestedTokens[$token] = true;
|
|
}
|
|
}
|
|
|
|
$kept = [];
|
|
foreach ($tokens as $token) {
|
|
if (isset($stopTokens[$token]) && !isset($requestedTokens[$token])) {
|
|
continue;
|
|
}
|
|
|
|
if (isset($kept[$token])) {
|
|
continue;
|
|
}
|
|
|
|
$kept[$token] = $token;
|
|
}
|
|
|
|
foreach (array_keys($requestedTokens) as $requestedToken) {
|
|
if (!isset($kept[$requestedToken])) {
|
|
$kept[$requestedToken] = $requestedToken;
|
|
}
|
|
}
|
|
|
|
if (count($kept) < max(1, $this->agentRunnerConfig->getDirectShopResultGuardPrimaryIdentityRepairMinQueryTokens())) {
|
|
return '';
|
|
}
|
|
|
|
return trim(implode(' ', array_values($kept)));
|
|
}
|
|
|
|
private function normalizeShopQueryForComparison(string $query): string
|
|
{
|
|
return trim(implode(' ', $this->tokenizeShopQueryCandidate($query)));
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function guardShopResultsByReferencedProductAnchor(string $shopSearchQuery, array $shopResults): array
|
|
{
|
|
if ($shopResults === []) {
|
|
return $shopResults;
|
|
}
|
|
|
|
$anchor = $this->referenceAnchorExtractor->extractFirstProductModelAnchor($shopSearchQuery);
|
|
if ($anchor === '') {
|
|
return $shopResults;
|
|
}
|
|
|
|
$filtered = [];
|
|
foreach ($shopResults as $product) {
|
|
if (!$product instanceof ShopProductResult) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->shopProductMatchesReferencedProductAnchor($product, $anchor)) {
|
|
$filtered[] = $product;
|
|
}
|
|
}
|
|
|
|
return $filtered;
|
|
}
|
|
|
|
private function shopProductMatchesReferencedProductAnchor(ShopProductResult $product, string $anchor): bool
|
|
{
|
|
$productText = trim(implode(' ', array_filter([
|
|
$product->name,
|
|
$product->description,
|
|
implode(' ', $product->highlights),
|
|
$product->customFields,
|
|
$product->url,
|
|
])));
|
|
|
|
return $this->containsAllShopQueryTokens($productText, $anchor);
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function guardShopResultsByExactRequestedAccessoryCode(string $prompt, string $shopSearchQuery, array $shopResults): array
|
|
{
|
|
if ($shopResults === []) {
|
|
return $shopResults;
|
|
}
|
|
|
|
$requestedCodes = $this->extractExactRequestedAccessoryCodes($prompt, $shopSearchQuery);
|
|
if ($requestedCodes === []) {
|
|
return $shopResults;
|
|
}
|
|
|
|
$filtered = [];
|
|
foreach ($shopResults as $product) {
|
|
if (!$product instanceof ShopProductResult) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->shopProductMatchesExactRequestedAccessoryCode($product, $requestedCodes)) {
|
|
$filtered[] = $product;
|
|
}
|
|
}
|
|
|
|
return $filtered !== [] ? $filtered : $shopResults;
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function extractExactRequestedAccessoryCodes(string $prompt, string $shopSearchQuery): array
|
|
{
|
|
$text = $this->normalizeOneLine(trim($prompt . ' ' . $shopSearchQuery));
|
|
if ($text === '') {
|
|
return [];
|
|
}
|
|
|
|
$codeTerms = $this->agentRunnerConfig->getRequestedAccessoryCodeTerms();
|
|
if ($codeTerms === []) {
|
|
return [];
|
|
}
|
|
|
|
$tokens = $this->tokenizeAccessoryCodeContext($text);
|
|
if ($tokens === []) {
|
|
return [];
|
|
}
|
|
|
|
$termTokenSequences = [];
|
|
foreach ($codeTerms as $term) {
|
|
$termTokens = $this->tokenizeAccessoryCodeContext($term);
|
|
if ($termTokens !== []) {
|
|
$termTokenSequences[] = $termTokens;
|
|
}
|
|
}
|
|
|
|
if ($termTokenSequences === []) {
|
|
return [];
|
|
}
|
|
|
|
$codes = [];
|
|
foreach ($termTokenSequences as $termTokens) {
|
|
$termLength = count($termTokens);
|
|
|
|
foreach ($tokens as $position => $_token) {
|
|
if (!$this->tokenSequenceMatchesAt($tokens, $termTokens, $position)) {
|
|
continue;
|
|
}
|
|
|
|
$code = $this->findNearestRequestedAccessoryCodeAfter($tokens, $position + $termLength, 3, $termTokenSequences);
|
|
if ($code === '') {
|
|
$code = $this->findNearestRequestedAccessoryCodeBefore($tokens, $position - 1, 3, $termTokenSequences);
|
|
}
|
|
|
|
if ($code !== '') {
|
|
$codes[$code] = $code;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values($codes);
|
|
}
|
|
|
|
/**
|
|
* @param string[] $tokens
|
|
* @param array<int, string[]> $termTokenSequences
|
|
*/
|
|
private function findNearestRequestedAccessoryCodeAfter(array $tokens, int $start, int $window, array $termTokenSequences): string
|
|
{
|
|
$end = min(count($tokens) - 1, $start + max(0, $window - 1));
|
|
for ($index = max(0, $start); $index <= $end; $index++) {
|
|
$code = $this->buildRequestedAccessoryCodeFromTokenWindow($tokens, $index, $termTokenSequences);
|
|
if ($code !== '') {
|
|
return $code;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* @param string[] $tokens
|
|
* @param array<int, string[]> $termTokenSequences
|
|
*/
|
|
private function findNearestRequestedAccessoryCodeBefore(array $tokens, int $start, int $window, array $termTokenSequences): string
|
|
{
|
|
$end = max(0, $start - max(0, $window - 1));
|
|
for ($index = min(count($tokens) - 1, $start); $index >= $end; $index--) {
|
|
$code = $this->buildRequestedAccessoryCodeFromTokenWindow($tokens, $index, $termTokenSequences);
|
|
if ($code !== '') {
|
|
return $code;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* @param string[] $tokens
|
|
* @param string[] $needle
|
|
*/
|
|
private function tokenSequenceMatchesAt(array $tokens, array $needle, int $position): bool
|
|
{
|
|
if ($needle === [] || $position < 0 || $position + count($needle) > count($tokens)) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($needle as $offset => $needleToken) {
|
|
if (($tokens[$position + $offset] ?? null) !== $needleToken) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $tokens
|
|
* @param array<int, string[]> $termTokenSequences
|
|
*/
|
|
private function buildRequestedAccessoryCodeFromTokenWindow(array $tokens, int $index, array $termTokenSequences): string
|
|
{
|
|
$token = $tokens[$index] ?? '';
|
|
if (!$this->isStrictAccessoryCodeToken($token)) {
|
|
return '';
|
|
}
|
|
|
|
$next = $tokens[$index + 1] ?? '';
|
|
if ($this->isSingleLetterVariantSuffix($next) && !$this->tokenStartsAnyConfiguredTerm($tokens, $termTokenSequences, $index + 1)) {
|
|
return $this->normalizeAccessoryCodePhrase($token . ' ' . $next);
|
|
}
|
|
|
|
$previous = $tokens[$index - 1] ?? '';
|
|
if ($this->isShortAlphaCodePrefix($previous) && !$this->tokenStartsAnyConfiguredTerm($tokens, $termTokenSequences, $index - 1)) {
|
|
return $this->normalizeAccessoryCodePhrase($previous . ' ' . $token);
|
|
}
|
|
|
|
return $this->normalizeAccessoryCodePhrase($token);
|
|
}
|
|
|
|
/**
|
|
* @param string[] $tokens
|
|
* @param array<int, string[]> $termTokenSequences
|
|
*/
|
|
private function tokenStartsAnyConfiguredTerm(array $tokens, array $termTokenSequences, int $position): bool
|
|
{
|
|
foreach ($termTokenSequences as $termTokens) {
|
|
if ($this->tokenSequenceMatchesAt($tokens, $termTokens, $position)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $requestedCodes
|
|
*/
|
|
private function shopProductMatchesExactRequestedAccessoryCode(ShopProductResult $product, array $requestedCodes): bool
|
|
{
|
|
$identityText = $this->normalizeOneLine(trim(implode(' ', array_filter([
|
|
$product->name,
|
|
$product->url,
|
|
]))));
|
|
|
|
if ($identityText === '') {
|
|
return false;
|
|
}
|
|
|
|
$tokens = $this->tokenizeAccessoryCodeContext($identityText);
|
|
if ($tokens === []) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($requestedCodes as $code) {
|
|
if ($this->accessoryCodeTokensContainExactCode($tokens, $code)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $tokens
|
|
*/
|
|
private function accessoryCodeTokensContainExactCode(array $tokens, string $requestedCode): bool
|
|
{
|
|
$codeTokens = $this->tokenizeAccessoryCodeContext($requestedCode);
|
|
if ($codeTokens === []) {
|
|
return false;
|
|
}
|
|
|
|
$compactCode = $this->normalizeAccessoryCodeForExactMatch($requestedCode);
|
|
$codeLength = count($codeTokens);
|
|
|
|
foreach ($tokens as $index => $token) {
|
|
if ($codeLength === 1 && $this->normalizeAccessoryCodeForExactMatch($token) === $compactCode) {
|
|
$next = $tokens[$index + 1] ?? '';
|
|
if (!$this->isSingleLetterVariantSuffix($next)) {
|
|
return true;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($this->tokenSequenceMatchesAt($tokens, $codeTokens, $index)) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->normalizeAccessoryCodeForExactMatch(implode(' ', array_slice($tokens, $index, $codeLength))) === $compactCode) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function tokenizeAccessoryCodeContext(string $text): array
|
|
{
|
|
$normalized = mb_strtolower($this->normalizeOneLine($text), 'UTF-8');
|
|
if ($normalized === '') {
|
|
return [];
|
|
}
|
|
|
|
preg_match_all('/[\p{L}]+\d[\p{L}\p{N}\-]*|\d+(?:[,.]\d+)?[\p{L}\p{N}\-]*|[\p{L}]+/u', $normalized, $matches);
|
|
|
|
return array_values(array_filter(
|
|
array_map(static fn(string $token): string => trim($token), $matches[0] ?? []),
|
|
static fn(string $token): bool => $token !== ''
|
|
));
|
|
}
|
|
|
|
private function isStrictAccessoryCodeToken(string $token): bool
|
|
{
|
|
$token = trim($token);
|
|
if ($token === '' || str_contains($token, ',') || str_contains($token, '.')) {
|
|
return false;
|
|
}
|
|
|
|
if (preg_match('/^\d+$/u', $token) === 1) {
|
|
return mb_strlen($token, 'UTF-8') >= 2;
|
|
}
|
|
|
|
foreach ($this->agentRunnerConfig->getShopQueryPositiveTokenFilterCodePatterns() as $pattern) {
|
|
if (@preg_match($pattern, $token) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return preg_match('/^(?:[a-z]{1,4}\d{1,5}[a-z0-9-]*|\d{2,5}[a-z0-9-]*)$/iu', $token) === 1;
|
|
}
|
|
|
|
private function isSingleLetterVariantSuffix(string $token): bool
|
|
{
|
|
return preg_match('/^[a-z]$/iu', trim($token)) === 1;
|
|
}
|
|
|
|
private function isShortAlphaCodePrefix(string $token): bool
|
|
{
|
|
return preg_match('/^[a-z]{1,4}$/iu', trim($token)) === 1;
|
|
}
|
|
|
|
private function normalizeAccessoryCodePhrase(string $code): string
|
|
{
|
|
return $this->normalizeOneLine(mb_strtolower($code, 'UTF-8'));
|
|
}
|
|
|
|
private function normalizeAccessoryCodeForExactMatch(string $code): string
|
|
{
|
|
return preg_replace('/[^a-z0-9]+/iu', '', mb_strtolower($code, 'UTF-8')) ?? '';
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function sortShopResultsForLengthRequest(string $prompt, string $shopSearchQuery, array $shopResults): array
|
|
{
|
|
if (
|
|
count($shopResults) < 2
|
|
|| !$this->agentRunnerConfig->isShopResultLengthSortEnabled()
|
|
|| !$this->isShopResultLengthSortRequested($prompt . ' ' . $shopSearchQuery)
|
|
) {
|
|
return $shopResults;
|
|
}
|
|
|
|
$hasLength = false;
|
|
$decorated = [];
|
|
|
|
foreach (array_values($shopResults) as $index => $product) {
|
|
$length = $product instanceof ShopProductResult
|
|
? $this->extractShopProductLengthMeters($product)
|
|
: null;
|
|
$hasLength = $hasLength || $length !== null;
|
|
$decorated[] = [
|
|
'index' => $index,
|
|
'length' => $length,
|
|
'product' => $product,
|
|
];
|
|
}
|
|
|
|
if (!$hasLength) {
|
|
return $shopResults;
|
|
}
|
|
|
|
return $this->sortDecoratedShopProductsByLength($decorated);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{index: int, length: float|null, product: mixed}> $decorated
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function sortDecoratedShopProductsByLength(array $decorated): array
|
|
{
|
|
usort($decorated, static function (array $a, array $b): int {
|
|
if ($a['length'] === null && $b['length'] === null) {
|
|
return $a['index'] <=> $b['index'];
|
|
}
|
|
|
|
if ($a['length'] === null) {
|
|
return 1;
|
|
}
|
|
|
|
if ($b['length'] === null) {
|
|
return -1;
|
|
}
|
|
|
|
$lengthCompare = $a['length'] <=> $b['length'];
|
|
|
|
return $lengthCompare !== 0 ? $lengthCompare : ($a['index'] <=> $b['index']);
|
|
});
|
|
|
|
return array_values(array_map(
|
|
static fn(array $row): mixed => $row['product'],
|
|
$decorated
|
|
));
|
|
}
|
|
|
|
private function isShopResultLengthSortRequested(string $text): bool
|
|
{
|
|
foreach ($this->agentRunnerConfig->getShopResultLengthSortTriggerPatterns() as $pattern) {
|
|
if (@preg_match($pattern, $text) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @return array{type: string, value: float}|null
|
|
*/
|
|
private function resolveShopResultLengthFilter(string $prompt, string $shopSearchQuery): ?array
|
|
{
|
|
if (!$this->agentRunnerConfig->isShopResultLengthFilterEnabled()) {
|
|
return null;
|
|
}
|
|
|
|
$text = trim($prompt . ' ' . $shopSearchQuery);
|
|
if ($text === '') {
|
|
return null;
|
|
}
|
|
|
|
foreach ($this->agentRunnerConfig->getShopResultMinLengthFilterPatterns() as $pattern) {
|
|
$value = $this->extractShopLengthFilterPatternValue($pattern, $text);
|
|
if ($value !== null) {
|
|
return ['type' => 'min', 'value' => $value];
|
|
}
|
|
}
|
|
|
|
foreach ($this->agentRunnerConfig->getShopResultMaxLengthFilterPatterns() as $pattern) {
|
|
$value = $this->extractShopLengthFilterPatternValue($pattern, $text);
|
|
if ($value !== null) {
|
|
return ['type' => 'max', 'value' => $value];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function extractShopLengthFilterPatternValue(string $pattern, string $text): ?float
|
|
{
|
|
if (@preg_match($pattern, $text, $matches) !== 1) {
|
|
return null;
|
|
}
|
|
|
|
$value = $matches['value'] ?? ($matches[1] ?? null);
|
|
if (!is_scalar($value)) {
|
|
return null;
|
|
}
|
|
|
|
$normalized = str_replace(',', '.', (string) $value);
|
|
if (!is_numeric($normalized)) {
|
|
return null;
|
|
}
|
|
|
|
return (float) $normalized;
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
* @param array{type: string, value: float} $lengthFilter
|
|
* @return ShopProductResult[]
|
|
*/
|
|
private function filterDirectShopAnswerResultsByLength(array $shopResults, array $lengthFilter): array
|
|
{
|
|
$decorated = [];
|
|
|
|
foreach (array_values($shopResults) as $index => $product) {
|
|
if (!$product instanceof ShopProductResult) {
|
|
continue;
|
|
}
|
|
|
|
$length = $this->extractShopProductLengthMeters($product);
|
|
if ($length === null) {
|
|
continue;
|
|
}
|
|
|
|
if (($lengthFilter['type'] === 'min' && $length < $lengthFilter['value'])
|
|
|| ($lengthFilter['type'] === 'max' && $length > $lengthFilter['value'])
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$decorated[] = [
|
|
'index' => $index,
|
|
'length' => $length,
|
|
'product' => $product,
|
|
];
|
|
}
|
|
|
|
return $this->sortDecoratedShopProductsByLength($decorated);
|
|
}
|
|
|
|
/**
|
|
* @param array{type: string, value: float} $lengthFilter
|
|
*/
|
|
private function buildDirectShopAnswerLengthFilterNote(array $lengthFilter): string
|
|
{
|
|
$template = $lengthFilter['type'] === 'max'
|
|
? $this->agentRunnerConfig->getDirectShopResultAnswerMaxLengthFilterNote()
|
|
: $this->agentRunnerConfig->getDirectShopResultAnswerMinLengthFilterNote();
|
|
|
|
return str_replace('{value}', $this->formatShopLengthFilterValue($lengthFilter['value']), $template);
|
|
}
|
|
|
|
private function formatShopLengthFilterValue(float $value): string
|
|
{
|
|
if (abs($value - round($value)) < 0.00001) {
|
|
return (string) (int) round($value);
|
|
}
|
|
|
|
return rtrim(rtrim(str_replace('.', ',', sprintf('%.2F', $value)), '0'), ',');
|
|
}
|
|
|
|
private function extractShopProductLengthMeters(ShopProductResult $product): ?float
|
|
{
|
|
$text = trim(implode(' ', array_filter([
|
|
$product->name,
|
|
$product->description,
|
|
implode(' ', $product->highlights),
|
|
$product->customFields,
|
|
])));
|
|
|
|
if ($text === '') {
|
|
return null;
|
|
}
|
|
|
|
foreach ($this->agentRunnerConfig->getShopResultLengthSortValuePatterns() as $pattern) {
|
|
if (@preg_match($pattern, $text, $matches) !== 1) {
|
|
continue;
|
|
}
|
|
|
|
$value = $matches['value'] ?? ($matches[1] ?? null);
|
|
if (!is_scalar($value)) {
|
|
continue;
|
|
}
|
|
|
|
$normalized = str_replace(',', '.', (string) $value);
|
|
if (is_numeric($normalized)) {
|
|
return (float) $normalized;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function extractRequestedDirectProductTerms(string $prompt, string $shopSearchQuery = ''): array
|
|
{
|
|
$combined = trim($prompt . ' ' . $shopSearchQuery);
|
|
if ($combined === '') {
|
|
return [];
|
|
}
|
|
|
|
$terms = [];
|
|
foreach ($this->agentRunnerConfig->getShopQueryProductAttributeCleanupProductTypeTerms() as $term) {
|
|
if ($this->containsAllShopQueryTokens($combined, $term)) {
|
|
$terms[] = $term;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($terms));
|
|
}
|
|
|
|
private function containsAllShopQueryTokens(string $text, string $term): bool
|
|
{
|
|
$tokens = array_fill_keys($this->tokenizeShopQueryCandidate($text), true);
|
|
$termTokens = $this->tokenizeShopQueryCandidate($term);
|
|
|
|
if ($tokens === [] || $termTokens === []) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($termTokens as $termToken) {
|
|
if (!isset($tokens[$termToken])) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $requestedTerms
|
|
*/
|
|
private function shopProductPrimaryIdentityMatchesAnyDirectProductTerm(ShopProductResult $product, array $requestedTerms): bool
|
|
{
|
|
$primaryText = trim(implode(' ', array_filter([
|
|
$product->name,
|
|
$product->url,
|
|
])));
|
|
|
|
return $this->textMatchesAnyDirectProductTerm($primaryText, $requestedTerms);
|
|
}
|
|
|
|
/**
|
|
* @param string[] $requestedTerms
|
|
*/
|
|
private function shopProductMatchesAnyDirectProductTerm(ShopProductResult $product, array $requestedTerms): bool
|
|
{
|
|
$productText = trim(implode(' ', array_filter([
|
|
$product->name,
|
|
$product->description,
|
|
implode(' ', $product->highlights),
|
|
$product->customFields,
|
|
$product->url,
|
|
])));
|
|
|
|
return $this->textMatchesAnyDirectProductTerm($productText, $requestedTerms);
|
|
}
|
|
|
|
/**
|
|
* @param string[] $requestedTerms
|
|
*/
|
|
private function textMatchesAnyDirectProductTerm(string $text, array $requestedTerms): bool
|
|
{
|
|
if (trim($text) === '') {
|
|
return false;
|
|
}
|
|
|
|
foreach ($requestedTerms as $term) {
|
|
if ($this->containsAllShopQueryTokens($text, $term)) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->containsAllShopQueryTokensWithCompoundPrefixes($text, $term)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function containsAllShopQueryTokensWithCompoundPrefixes(string $text, string $term): bool
|
|
{
|
|
if (!$this->agentRunnerConfig->isDirectShopResultGuardCompoundPrefixMatchEnabled()) {
|
|
return false;
|
|
}
|
|
|
|
$tokens = $this->tokenizeShopQueryCandidate($text);
|
|
$termTokens = $this->tokenizeShopQueryCandidate($term);
|
|
|
|
if ($tokens === [] || $termTokens === []) {
|
|
return false;
|
|
}
|
|
|
|
$exactTokens = array_fill_keys($tokens, true);
|
|
|
|
foreach ($termTokens as $termToken) {
|
|
if (isset($exactTokens[$termToken])) {
|
|
continue;
|
|
}
|
|
|
|
if (!$this->directProductTermAllowsCompoundPrefixMatch($termToken)) {
|
|
return false;
|
|
}
|
|
|
|
$matchedPrefix = false;
|
|
$termTokenLength = mb_strlen($termToken, 'UTF-8');
|
|
|
|
foreach ($tokens as $token) {
|
|
if (mb_strlen($token, 'UTF-8') <= $termTokenLength) {
|
|
continue;
|
|
}
|
|
|
|
if (mb_substr($token, 0, $termTokenLength, 'UTF-8') === $termToken) {
|
|
$matchedPrefix = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$matchedPrefix) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function directProductTermAllowsCompoundPrefixMatch(string $termToken): bool
|
|
{
|
|
foreach ($this->agentRunnerConfig->getDirectShopResultGuardCompoundPrefixTerms() as $configuredTerm) {
|
|
foreach ($this->tokenizeShopQueryCandidate($configuredTerm) as $configuredToken) {
|
|
if ($termToken === $configuredToken) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
*/
|
|
private function buildDeterministicDirectShopResultAnswer(
|
|
string $prompt,
|
|
array $shopResults,
|
|
string $commerceIntent,
|
|
bool $shopSearchAttempted,
|
|
bool $shopSearchHadSystemFailure,
|
|
string $shopSearchQuery
|
|
): string {
|
|
if (
|
|
!$this->agentRunnerConfig->isDirectShopResultAnswerEnabled()
|
|
|| !$this->isCommerceIntent($commerceIntent)
|
|
|| !$shopSearchAttempted
|
|
|| $shopSearchHadSystemFailure
|
|
|| $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery) === []
|
|
) {
|
|
return '';
|
|
}
|
|
|
|
$answerShopResults = $shopResults;
|
|
$lengthFilter = $this->resolveShopResultLengthFilter($prompt, $shopSearchQuery);
|
|
if ($lengthFilter !== null) {
|
|
$answerShopResults = $this->filterDirectShopAnswerResultsByLength($answerShopResults, $lengthFilter);
|
|
}
|
|
|
|
if ($answerShopResults === []) {
|
|
return $this->agentRunnerConfig->getDirectShopResultAnswerNoResultsMessage();
|
|
}
|
|
|
|
$lines = [$this->agentRunnerConfig->getDirectShopResultAnswerIntro()];
|
|
|
|
if ($lengthFilter !== null) {
|
|
$note = trim($this->buildDirectShopAnswerLengthFilterNote($lengthFilter));
|
|
if ($note !== '') {
|
|
$lines[] = $note;
|
|
}
|
|
}
|
|
|
|
if ($lengthFilter !== null || $this->isShopResultLengthSortRequested($prompt . ' ' . $shopSearchQuery)) {
|
|
$note = trim($this->agentRunnerConfig->getDirectShopResultAnswerSortedByLengthNote());
|
|
if ($note !== '') {
|
|
$lines[] = $note;
|
|
}
|
|
}
|
|
|
|
$lines[] = '';
|
|
foreach ($this->buildDirectShopProductLines($answerShopResults, 'accessory_or_consumable') as $line) {
|
|
$lines[] = $line;
|
|
}
|
|
|
|
return trim(implode("\n", $lines));
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
* @return string[]
|
|
*/
|
|
private function buildDirectShopProductLines(array $shopResults, string $requestedProductRole): array
|
|
{
|
|
$maxResults = max(1, $this->agentRunnerConfig->getDirectShopResultAnswerMaxResults());
|
|
$lines = [];
|
|
$index = 1;
|
|
|
|
foreach ($shopResults as $product) {
|
|
if (!$product instanceof ShopProductResult) {
|
|
continue;
|
|
}
|
|
|
|
$lines[] = $this->formatNoLlmShopProductLine($product, $index, $requestedProductRole);
|
|
$index++;
|
|
|
|
if (count($lines) >= $maxResults) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($lines === []) {
|
|
return [$this->agentRunnerConfig->getNoLlmProductField('unreadable_results_message')];
|
|
}
|
|
|
|
return $lines;
|
|
}
|
|
|
|
/**
|
|
* Build a deterministic safety answer for environments where the LLM returns no tokens.
|
|
*
|
|
* This intentionally does not infer technical suitability from weak evidence. It only reports
|
|
* reliable system state, shop hit metadata and the next safe action.
|
|
*
|
|
* @param string[] $knowledgeChunks
|
|
* @param ShopProductResult[] $shopResults
|
|
*/
|
|
private function buildNoLlmFallbackAnswer(
|
|
string $prompt,
|
|
string $urlContent,
|
|
array $knowledgeChunks,
|
|
array $shopResults,
|
|
string $commerceIntent,
|
|
bool $shopSearchAttempted,
|
|
bool $shopSearchHadSystemFailure,
|
|
?string $shopSearchFailureReason,
|
|
string $knowledgeEvidenceState = 'unknown'
|
|
): string {
|
|
$hasKnowledge = $this->isDirectKnowledgeEvidence($knowledgeEvidenceState) || ($knowledgeEvidenceState === 'unknown' && ($knowledgeChunks !== [] || trim($urlContent) !== ''));
|
|
$hasShopResults = $shopResults !== [];
|
|
$isCommerceIntent = $this->isCommerceIntent($commerceIntent);
|
|
|
|
if ($hasShopResults) {
|
|
return $this->buildNoLlmShopFallbackAnswer(
|
|
prompt: $prompt,
|
|
hasKnowledge: $hasKnowledge,
|
|
shopResults: $shopResults
|
|
);
|
|
}
|
|
|
|
if ($shopSearchHadSystemFailure) {
|
|
return $this->buildNoLlmShopUnavailableAnswer(
|
|
hasKnowledge: $hasKnowledge,
|
|
reason: $shopSearchFailureReason
|
|
);
|
|
}
|
|
|
|
if ($isCommerceIntent && $shopSearchAttempted) {
|
|
return $this->buildNoLlmNoShopResultsAnswer($hasKnowledge);
|
|
}
|
|
|
|
if ($hasKnowledge) {
|
|
return $this->agentRunnerConfig->getNoLlmFallbackKnowledgeOnlyMessage();
|
|
}
|
|
|
|
return $this->agentRunnerConfig->getNoLlmFallbackNoDataMessage();
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
*/
|
|
private function buildNoLlmShopFallbackAnswer(string $prompt, bool $hasKnowledge, array $shopResults): string
|
|
{
|
|
$intro = $hasKnowledge
|
|
? $this->agentRunnerConfig->getNoLlmFallbackShopWithKnowledgeMessage()
|
|
: $this->agentRunnerConfig->getNoLlmFallbackShopOnlyMessage();
|
|
$requestedProductRole = $this->resolveNoLlmRequestedProductRole($prompt);
|
|
|
|
$lines = [$intro];
|
|
|
|
if ($this->hasOnlyNoLlmAccessoryResultsForMainDeviceRequest($requestedProductRole, $shopResults)) {
|
|
$lines[] = $this->agentRunnerConfig->getNoLlmFallbackAccessoryOnlyForMainDeviceMessage();
|
|
}
|
|
|
|
$lines[] = '';
|
|
|
|
foreach ($this->buildNoLlmShopProductLines($shopResults, $requestedProductRole) as $line) {
|
|
$lines[] = $line;
|
|
}
|
|
|
|
$escalation = trim($this->agentRunnerConfig->getNoLlmFallbackEscalationMessage());
|
|
if ($escalation !== '') {
|
|
$lines[] = '';
|
|
$lines[] = $escalation;
|
|
}
|
|
|
|
return trim(implode("\n", $lines));
|
|
}
|
|
|
|
private function buildNoLlmShopUnavailableAnswer(bool $hasKnowledge, ?string $reason): string
|
|
{
|
|
$reason = $this->normalizeOneLine((string) $reason);
|
|
$message = $hasKnowledge
|
|
? $this->agentRunnerConfig->getNoLlmFallbackShopUnavailableWithKnowledgeMessage()
|
|
: $this->agentRunnerConfig->getNoLlmFallbackShopUnavailableNoKnowledgeMessage();
|
|
|
|
if ($reason !== '') {
|
|
$message = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('unavailable_reason_template'), [
|
|
'message' => $message,
|
|
'reason' => $reason,
|
|
]);
|
|
}
|
|
|
|
return trim($message);
|
|
}
|
|
|
|
private function buildNoLlmNoShopResultsAnswer(bool $hasKnowledge): string
|
|
{
|
|
return $hasKnowledge
|
|
? $this->agentRunnerConfig->getNoLlmFallbackNoShopResultsWithKnowledgeMessage()
|
|
: $this->agentRunnerConfig->getNoLlmFallbackNoShopResultsNoKnowledgeMessage();
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
* @return string[]
|
|
*/
|
|
private function buildNoLlmShopProductLines(array $shopResults, string $requestedProductRole): array
|
|
{
|
|
$maxResults = max(1, $this->agentRunnerConfig->getNoLlmFallbackMaxShopResults());
|
|
$lines = [];
|
|
$index = 1;
|
|
|
|
foreach ($shopResults as $product) {
|
|
if (!$product instanceof ShopProductResult) {
|
|
continue;
|
|
}
|
|
|
|
$lines[] = $this->formatNoLlmShopProductLine($product, $index, $requestedProductRole);
|
|
$index++;
|
|
|
|
if (count($lines) >= $maxResults) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($lines === []) {
|
|
return [$this->agentRunnerConfig->getNoLlmProductField('unreadable_results_message')];
|
|
}
|
|
|
|
return $lines;
|
|
}
|
|
|
|
private function formatNoLlmShopProductLine(ShopProductResult $product, int $index, string $requestedProductRole): string
|
|
{
|
|
$parts = [];
|
|
$productRole = $this->resolveNoLlmShopProductRole($product);
|
|
|
|
$name = $this->normalizeOneLine($product->name);
|
|
$parts[] = $name !== '' ? $name : $this->agentRunnerConfig->getNoLlmProductField('unnamed_product');
|
|
|
|
if ($product->productNumber !== null && trim($product->productNumber) !== '') {
|
|
$parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('product_number_template'), ['value' => $this->normalizeOneLine($product->productNumber)]);
|
|
}
|
|
|
|
if ($product->manufacturer !== null && trim($product->manufacturer) !== '') {
|
|
$parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('manufacturer_template'), ['value' => $this->normalizeOneLine($product->manufacturer)]);
|
|
}
|
|
|
|
if ($product->price !== null && trim($product->price) !== '') {
|
|
$parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('price_template'), ['value' => $this->normalizeOneLine($product->price)]);
|
|
}
|
|
|
|
if ($product->available !== null) {
|
|
$parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('availability_template'), ['value' => $product->available ? $this->agentRunnerConfig->getNoLlmProductField('availability_yes') : $this->agentRunnerConfig->getNoLlmProductField('availability_no')]);
|
|
}
|
|
|
|
if ($product->url !== null && trim($product->url) !== '') {
|
|
$parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('url_template'), ['value' => $this->normalizeOneLine($product->url)]);
|
|
}
|
|
|
|
if ($requestedProductRole === 'main_device_or_system' && $productRole === 'accessory_or_consumable') {
|
|
$parts[] = $this->agentRunnerConfig->getNoLlmProductField('incompatible_role_note');
|
|
}
|
|
|
|
return $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('line_template'), [
|
|
'index' => (string) $index,
|
|
'parts' => implode($this->agentRunnerConfig->getNoLlmProductField('separator'), $parts),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
*/
|
|
private function hasOnlyNoLlmAccessoryResultsForMainDeviceRequest(string $requestedProductRole, array $shopResults): bool
|
|
{
|
|
if ($requestedProductRole !== 'main_device_or_system') {
|
|
return false;
|
|
}
|
|
|
|
$seenProducts = 0;
|
|
|
|
foreach ($shopResults as $product) {
|
|
if (!$product instanceof ShopProductResult) {
|
|
continue;
|
|
}
|
|
|
|
$seenProducts++;
|
|
|
|
if ($this->resolveNoLlmShopProductRole($product) !== 'accessory_or_consumable') {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return $seenProducts > 0;
|
|
}
|
|
|
|
private function resolveNoLlmRequestedProductRole(string $prompt): string
|
|
{
|
|
$normalized = mb_strtolower($prompt, 'UTF-8');
|
|
|
|
if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords())) {
|
|
return 'accessory_or_consumable';
|
|
}
|
|
|
|
if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords())) {
|
|
return 'main_device_or_system';
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
private function resolveNoLlmShopProductRole(ShopProductResult $product): string
|
|
{
|
|
$normalized = mb_strtolower($this->normalizeOneLine(implode(' ', [
|
|
$product->name,
|
|
(string) $product->description,
|
|
(string) $product->customFields,
|
|
implode(' ', $product->highlights),
|
|
])), 'UTF-8');
|
|
|
|
if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords())) {
|
|
return 'accessory_or_consumable';
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
/**
|
|
* @param string[] $terms
|
|
*/
|
|
private function containsAnyConfiguredTerm(string $haystack, array $terms): bool
|
|
{
|
|
foreach ($terms as $term) {
|
|
$term = mb_strtolower(trim($term), 'UTF-8');
|
|
|
|
if ($term !== '' && str_contains($haystack, $term)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Distinguish semantic nearest-neighbor retrieval hits from direct factual evidence.
|
|
*
|
|
* Vector retrieval can return useful context even when the essential user term is not
|
|
* present. Those hits should stay visible as RAG hits, but they must not be counted as
|
|
* "fachlich belegt" unless at least one salient request term or configured synonym
|
|
* appears in the retrieved knowledge or user-provided URL content.
|
|
*
|
|
* @param string[] $knowledgeChunks
|
|
*/
|
|
private function resolveKnowledgeEvidenceState(string $prompt, array $knowledgeChunks, string $urlContent): string
|
|
{
|
|
if ($knowledgeChunks === [] && trim($urlContent) === '') {
|
|
return 'none';
|
|
}
|
|
|
|
if (trim($urlContent) !== '') {
|
|
return 'direct';
|
|
}
|
|
|
|
$needles = $this->buildRagEvidenceNeedles($prompt);
|
|
|
|
if ($needles === []) {
|
|
// No meaningful term could be extracted. Preserve the previous behavior for
|
|
// very short follow-ups instead of hiding potentially valid context.
|
|
return 'direct';
|
|
}
|
|
|
|
$haystack = $this->normalizeRagEvidenceText(implode("\n\n", array_map('strval', $knowledgeChunks)));
|
|
$isAggregateQuery = $this->isAggregateRagEvidenceQuery($prompt);
|
|
|
|
if (
|
|
$isAggregateQuery
|
|
&& !$this->containsAnyRagEvidencePattern($haystack, $this->agentRunnerConfig->getRagEvidenceAggregateAnswerEvidencePatterns())
|
|
) {
|
|
return 'aggregate_missing';
|
|
}
|
|
|
|
if (
|
|
$isAggregateQuery
|
|
&& !$this->containsAnyRagEvidenceTerm($haystack, $this->agentRunnerConfig->getRagEvidenceAggregateEvidenceTerms())
|
|
) {
|
|
return 'aggregate_missing';
|
|
}
|
|
|
|
foreach ($needles as $needleGroup) {
|
|
foreach ($needleGroup as $needle) {
|
|
if ($this->containsRagEvidenceTerm($haystack, $needle)) {
|
|
return 'direct';
|
|
}
|
|
}
|
|
}
|
|
|
|
return 'weak';
|
|
}
|
|
|
|
private function isDirectKnowledgeEvidence(string $knowledgeEvidenceState): bool
|
|
{
|
|
return $knowledgeEvidenceState === 'direct';
|
|
}
|
|
|
|
private function resolveRagEvidenceConfidenceLabel(string $knowledgeEvidenceState): string
|
|
{
|
|
return match ($knowledgeEvidenceState) {
|
|
'direct' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct'),
|
|
'aggregate_missing' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing'),
|
|
'weak' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('weak'),
|
|
default => $this->agentRunnerConfig->getProductionUiConfidenceLabel('default'),
|
|
};
|
|
}
|
|
|
|
private function resolveRagEvidenceShopCheckConfidenceLabel(string $knowledgeEvidenceState): string
|
|
{
|
|
return match ($knowledgeEvidenceState) {
|
|
'direct' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct_shop_check'),
|
|
'aggregate_missing' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_shop_check'),
|
|
'weak' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('weak_shop_check'),
|
|
default => $this->agentRunnerConfig->getProductionUiConfidenceLabel('default_shop_check'),
|
|
};
|
|
}
|
|
|
|
private function isAggregateRagEvidenceQuery(string $prompt): bool
|
|
{
|
|
$normalizedPrompt = $this->normalizeRagEvidenceText($prompt);
|
|
|
|
if ($normalizedPrompt === '') {
|
|
return false;
|
|
}
|
|
|
|
foreach ($this->agentRunnerConfig->getRagEvidenceAggregateQueryPatterns() as $pattern) {
|
|
if (@preg_match($pattern, $normalizedPrompt) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $terms
|
|
*/
|
|
private function containsAnyRagEvidenceTerm(string $haystack, array $terms): bool
|
|
{
|
|
foreach ($terms as $term) {
|
|
if ($this->containsRagEvidenceTerm($haystack, $term)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $patterns
|
|
*/
|
|
private function containsAnyRagEvidencePattern(string $haystack, array $patterns): bool
|
|
{
|
|
foreach ($patterns as $pattern) {
|
|
if (@preg_match($pattern, $haystack) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string[]>
|
|
*/
|
|
private function buildRagEvidenceNeedles(string $prompt): array
|
|
{
|
|
$normalizedPrompt = $this->normalizeRagEvidenceText($prompt);
|
|
$stopTerms = [];
|
|
|
|
foreach ($this->getRagEvidenceCleanupStopTerms() as $term) {
|
|
$term = $this->normalizeRagEvidenceText($term);
|
|
if ($term !== '') {
|
|
$stopTerms[$term] = true;
|
|
}
|
|
}
|
|
|
|
preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}\-]{1,}/u', $normalizedPrompt, $matches);
|
|
$tokens = $matches[0] ?? [];
|
|
$groups = [];
|
|
$synonyms = $this->normalizedRagEvidenceSynonyms();
|
|
|
|
foreach ($tokens as $token) {
|
|
$token = trim((string) $token);
|
|
|
|
if ($token === '' || isset($stopTerms[$token]) || mb_strlen($token, 'UTF-8') < 3) {
|
|
continue;
|
|
}
|
|
|
|
$group = $synonyms[$token] ?? [$token];
|
|
$group = array_values(array_unique(array_filter(array_map(
|
|
fn (string $item): string => $this->normalizeRagEvidenceText($item),
|
|
$group
|
|
))));
|
|
|
|
if ($group !== []) {
|
|
$groups[] = $group;
|
|
}
|
|
}
|
|
|
|
return $groups;
|
|
}
|
|
|
|
/** @return string[] */
|
|
private function getRagEvidenceCleanupStopTerms(): array
|
|
{
|
|
return $this->mergeUniqueStrings(
|
|
$this->languageCleanupConfig->getStopWordsForProfile($this->agentRunnerConfig->getRagEvidenceCleanupProfile()),
|
|
$this->agentRunnerConfig->getRagEvidenceStopTerms()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param string[] $left
|
|
* @param string[] $right
|
|
* @return string[]
|
|
*/
|
|
private function mergeUniqueStrings(array $left, array $right): array
|
|
{
|
|
$out = [];
|
|
foreach (array_merge($left, $right) as $item) {
|
|
$item = trim((string) $item);
|
|
if ($item === '' || isset($out[$item])) {
|
|
continue;
|
|
}
|
|
|
|
$out[$item] = $item;
|
|
}
|
|
|
|
return array_values($out);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string[]>
|
|
*/
|
|
private function normalizedRagEvidenceSynonyms(): array
|
|
{
|
|
$out = [];
|
|
|
|
foreach ($this->agentRunnerConfig->getRagEvidenceSynonyms() as $key => $items) {
|
|
$key = $this->normalizeRagEvidenceText((string) $key);
|
|
if ($key === '') {
|
|
continue;
|
|
}
|
|
|
|
$terms = [];
|
|
foreach ($items as $item) {
|
|
$item = $this->normalizeRagEvidenceText((string) $item);
|
|
if ($item !== '' && !in_array($item, $terms, true)) {
|
|
$terms[] = $item;
|
|
}
|
|
}
|
|
|
|
if (!in_array($key, $terms, true)) {
|
|
$terms[] = $key;
|
|
}
|
|
|
|
$out[$key] = $terms;
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
private function containsRagEvidenceTerm(string $haystack, string $needle): bool
|
|
{
|
|
$needle = $this->normalizeRagEvidenceText($needle);
|
|
|
|
if ($haystack === '' || $needle === '') {
|
|
return false;
|
|
}
|
|
|
|
$pattern = '/(?<![\p{L}\p{N}])' . preg_quote($needle, '/') . '(?![\p{L}\p{N}])/u';
|
|
|
|
return preg_match($pattern, $haystack) === 1;
|
|
}
|
|
|
|
private function normalizeRagEvidenceText(string $value): string
|
|
{
|
|
$value = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
$value = mb_strtolower($value, 'UTF-8');
|
|
$value = $this->languageCleanupConfig->normalizeDashEquivalents($value);
|
|
$value = $this->languageCleanupConfig->transliterateToAscii($value);
|
|
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
|
|
|
return trim($value);
|
|
}
|
|
|
|
/**
|
|
* @param string[] $sources
|
|
*/
|
|
private function emitSources(array $sources, string $prefix): string
|
|
{
|
|
return $this->systemMsg($prefix . implode(' ', $sources), 'info');
|
|
}
|
|
|
|
/**
|
|
* @param string[] $sources
|
|
*/
|
|
private function addSource(array &$sources, string $label): void
|
|
{
|
|
$badge = $this->badge($label);
|
|
|
|
if (!in_array($badge, $sources, true)) {
|
|
$sources[] = $badge;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param string[] $notices
|
|
*/
|
|
private function buildHistoryResponse(string $fullOutput, array $notices): string
|
|
{
|
|
$parts = [];
|
|
|
|
foreach ($notices as $notice) {
|
|
$notice = trim($notice);
|
|
|
|
if ($notice !== '') {
|
|
$parts[] = $notice;
|
|
}
|
|
}
|
|
|
|
$fullOutput = trim($fullOutput);
|
|
|
|
if ($fullOutput !== '') {
|
|
$parts[] = $fullOutput;
|
|
} else {
|
|
$noLlmMessage = $this->plainTextFromHtml($this->agentRunnerConfig->getNoLlmDataReceivedMessage());
|
|
|
|
if ($noLlmMessage === '') {
|
|
$noLlmMessage = $this->agentRunnerConfig->getProductionUiText('no_llm_history_default');
|
|
}
|
|
|
|
$parts[] = $this->renderAgentTemplate(
|
|
$this->agentRunnerConfig->getHistoryResponseSystemNoticeTemplate(),
|
|
['message' => $noLlmMessage]
|
|
);
|
|
}
|
|
|
|
return trim(implode("\n\n", $parts));
|
|
}
|
|
|
|
private function buildHistoryNotice(string $title, ?string $detail): string
|
|
{
|
|
$title = $this->normalizeOneLine($this->plainTextFromHtml($title));
|
|
$detail = $this->normalizeOneLine($this->plainTextFromHtml((string) $detail));
|
|
|
|
if ($title === '') {
|
|
$title = $this->agentRunnerConfig->getProductionUiText('history_notice_default_title');
|
|
}
|
|
|
|
if ($detail === '') {
|
|
return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('history_notice_without_detail'), ['title' => $title]);
|
|
}
|
|
|
|
if (mb_strlen($detail, 'UTF-8') > 500) {
|
|
$detail = rtrim(mb_substr($detail, 0, 497, 'UTF-8')) . '...';
|
|
}
|
|
|
|
return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('history_notice_with_detail'), [
|
|
'title' => $title,
|
|
'detail' => $detail,
|
|
]);
|
|
}
|
|
|
|
private function plainTextFromHtml(string $value): string
|
|
{
|
|
$value = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
|
|
|
return trim($value);
|
|
}
|
|
|
|
private function resolveShopCountModeForMeta(
|
|
string $commerceIntent,
|
|
bool $shopSearchAttempted,
|
|
bool $shopSearchHadSystemFailure,
|
|
bool $shopSearchSkippedBecauseNoQuery = false
|
|
): string {
|
|
if ($shopSearchHadSystemFailure) {
|
|
return 'unavailable';
|
|
}
|
|
|
|
if ($shopSearchAttempted) {
|
|
return 'count';
|
|
}
|
|
|
|
if ($shopSearchSkippedBecauseNoQuery) {
|
|
return 'not_resolved';
|
|
}
|
|
|
|
if ($this->isCommerceIntent($commerceIntent)) {
|
|
return 'loading';
|
|
}
|
|
|
|
return 'not_requested';
|
|
}
|
|
|
|
/**
|
|
* @param string[] $sourceLabels
|
|
*/
|
|
private function buildProductionUiMetaMessage(
|
|
string $stageLabel,
|
|
?int $ragCount,
|
|
?int $shopCount,
|
|
string $shopCountMode,
|
|
array $sourceLabels,
|
|
string $confidenceLabel,
|
|
bool $completed = false
|
|
): string {
|
|
$state = $completed ? 'completed' : 'running';
|
|
$ragLabel = $ragCount === null
|
|
? $this->agentRunnerConfig->getProductionUiText('rag_hits_checking')
|
|
: $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('rag_hits_count'), ['count' => (string) max(0, $ragCount)]);
|
|
$shopLabel = match ($shopCountMode) {
|
|
'count' => $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_hits_count'), ['count' => (string) max(0, (int) $shopCount)]),
|
|
'loading' => $this->agentRunnerConfig->getProductionUiText('shop_hits_loading'),
|
|
'unavailable' => $this->agentRunnerConfig->getProductionUiText('shop_hits_unavailable'),
|
|
'not_resolved' => $this->agentRunnerConfig->getProductionUiText('shop_hits_no_query'),
|
|
default => $this->agentRunnerConfig->getProductionUiText('shop_hits_not_requested'),
|
|
};
|
|
$statusLabel = $completed
|
|
? $this->agentRunnerConfig->getProductionUiText('status_completed')
|
|
: $this->agentRunnerConfig->getProductionUiText('status_running');
|
|
$sources = $this->formatProductionUiSourceLabels($sourceLabels);
|
|
|
|
$html = '<div class="retriex-meta-card retriex-run-meta" data-retriex-meta-id="run-status" data-retriex-meta-state="'
|
|
. htmlspecialchars($state, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
. '">'
|
|
. '<div class="retriex-meta-card__eyebrow">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('run_status_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
|
|
. '<div class="retriex-meta-card__title">' . htmlspecialchars($stageLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
|
|
. '<div class="retriex-meta-card__body">'
|
|
. '<span class="retriex-meta-pill retriex-meta-pill--status">' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
|
. '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($ragLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
|
. '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($shopLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
|
. '<span class="retriex-meta-pill retriex-meta-pill--confidence">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('evidence_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
|
. '</div>';
|
|
|
|
if ($sources !== []) {
|
|
$html .= '<div class="retriex-source-overview"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('data_basis_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><div class="retriex-source-overview__badges">';
|
|
|
|
foreach ($sources as $source) {
|
|
$html .= '<span class="retriex-source-chip">' . htmlspecialchars($source, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';
|
|
}
|
|
|
|
$html .= '</div></div>';
|
|
} else {
|
|
$emptySourceLabel = $completed
|
|
? $this->agentRunnerConfig->getProductionUiText('data_basis_empty_completed')
|
|
: $this->agentRunnerConfig->getProductionUiText('data_basis_empty_running');
|
|
$html .= '<div class="retriex-source-overview"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('data_basis_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><div class="retriex-source-overview__empty">'
|
|
. htmlspecialchars($emptySourceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
. '</div></div>';
|
|
}
|
|
|
|
$html .= '</div>';
|
|
|
|
return $html;
|
|
}
|
|
|
|
private function resolveProductionUiConfidenceLabel(
|
|
bool $hasKnowledge,
|
|
bool $isCommerceIntent,
|
|
bool $shopSearchAttempted,
|
|
bool $hasShopResults,
|
|
bool $shopSearchHadSystemFailure,
|
|
string $knowledgeEvidenceState = 'unknown'
|
|
): string {
|
|
if ($knowledgeEvidenceState === 'aggregate_missing' && !$hasShopResults) {
|
|
return $shopSearchHadSystemFailure
|
|
? $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_shop_unavailable')
|
|
: $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_no_count');
|
|
}
|
|
|
|
if ($shopSearchHadSystemFailure) {
|
|
return $hasKnowledge
|
|
? $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_unavailable_with_knowledge')
|
|
: $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_unavailable');
|
|
}
|
|
|
|
if ($hasKnowledge && $hasShopResults) {
|
|
return $this->agentRunnerConfig->getProductionUiConfidenceLabel('rag_and_shop');
|
|
}
|
|
|
|
if (!$hasKnowledge && $hasShopResults) {
|
|
return $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_only');
|
|
}
|
|
|
|
if ($hasKnowledge && $shopSearchAttempted) {
|
|
return $this->agentRunnerConfig->getProductionUiConfidenceLabel('rag_no_shop_hits');
|
|
}
|
|
|
|
if ($hasKnowledge) {
|
|
return $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct');
|
|
}
|
|
|
|
if ($isCommerceIntent || $shopSearchAttempted) {
|
|
return $this->agentRunnerConfig->getProductionUiConfidenceLabel('no_reliable_data');
|
|
}
|
|
|
|
return $this->agentRunnerConfig->getProductionUiConfidenceLabel('no_reliable_hits');
|
|
}
|
|
|
|
/**
|
|
* @param string[] $sourceLabels
|
|
* @return string[]
|
|
*/
|
|
private function formatProductionUiSourceLabels(array $sourceLabels): array
|
|
{
|
|
$labels = [];
|
|
|
|
foreach ($sourceLabels as $label) {
|
|
// Source labels are stored as badge HTML for the legacy "Genutzte Quellen" line.
|
|
// The production UI data-basis chips must render plain labels, otherwise the
|
|
// nested badge markup is escaped and shown as visible text.
|
|
$label = $this->plainTextFromHtml((string) $label);
|
|
|
|
if ($label === '') {
|
|
continue;
|
|
}
|
|
|
|
if ($label === $this->plainTextFromHtml($this->agentRunnerConfig->getShopSystemSourceLabel())) {
|
|
$label = $this->agentRunnerConfig->getProductionUiText('live_shop_source_plain_label');
|
|
}
|
|
|
|
if (!in_array($label, $labels, true)) {
|
|
$labels[] = $label;
|
|
}
|
|
}
|
|
|
|
return $labels;
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
*/
|
|
private function buildShopProductCardsMessage(array $shopResults, string $query, bool $usedRepair): string
|
|
{
|
|
$maxCards = max(1, $this->agentRunnerConfig->getProductionUiShopResultsMaxCards());
|
|
$visibleResults = array_slice($shopResults, 0, $maxCards);
|
|
$totalCount = count($shopResults);
|
|
$query = $this->normalizeOneLine($query);
|
|
$summary = $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_results_summary'), ['count' => (string) $totalCount]);
|
|
|
|
if ($totalCount > $maxCards) {
|
|
$summary .= $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_results_top_displayed_suffix'), ['max' => (string) $maxCards]);
|
|
}
|
|
|
|
if ($usedRepair) {
|
|
$summary .= $this->agentRunnerConfig->getProductionUiTemplate('shop_results_repair_suffix');
|
|
}
|
|
|
|
$html = '<div class="retriex-meta-card retriex-product-results" data-retriex-meta-id="shop-results" data-retriex-meta-state="completed">'
|
|
. '<div class="retriex-meta-card__eyebrow">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_results_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
|
|
. '<div class="retriex-meta-card__title">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_results_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
|
|
. '<div class="retriex-product-results__summary">' . htmlspecialchars($summary, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>';
|
|
|
|
if ($query !== '') {
|
|
$html .= '<div class="retriex-meta-query retriex-meta-query--compact"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('evaluated_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><code>'
|
|
. htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
. '</code></div>';
|
|
}
|
|
|
|
$html .= '<div class="retriex-product-grid">';
|
|
|
|
foreach ($visibleResults as $product) {
|
|
if (!$product instanceof ShopProductResult) {
|
|
continue;
|
|
}
|
|
|
|
$html .= $this->buildShopProductCard($product, $query);
|
|
}
|
|
|
|
$html .= '</div></div>';
|
|
|
|
return $html;
|
|
}
|
|
|
|
private function buildShopProductCard(ShopProductResult $product, string $query): string
|
|
{
|
|
$name = $this->normalizeOneLine($product->name) ?: $this->agentRunnerConfig->getProductionUiText('unnamed_product');
|
|
$productNumber = $this->normalizeOneLine((string) $product->productNumber);
|
|
$manufacturer = $this->normalizeOneLine((string) $product->manufacturer);
|
|
$price = $this->normalizeOneLine((string) $product->price);
|
|
$url = $this->normalizeOneLine((string) $product->url);
|
|
$availability = $this->formatProductAvailability($product->available);
|
|
$relevance = $this->buildProductRelevanceLabel($product, $query);
|
|
|
|
$html = '<article class="retriex-product-card">'
|
|
. '<div class="retriex-product-card__title">';
|
|
|
|
if ($url !== '') {
|
|
$html .= '<a href="' . htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '">'
|
|
. htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
. '</a>';
|
|
} else {
|
|
$html .= htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
}
|
|
|
|
$html .= '</div><dl class="retriex-product-card__facts">';
|
|
$html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('product_number_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($productNumber !== '' ? $productNumber : $this->agentRunnerConfig->getProductionUiText('field_not_provided'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
|
|
$html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('price_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($price !== '' ? $price : $this->agentRunnerConfig->getProductionUiText('field_not_provided'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
|
|
$html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('availability_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
|
|
|
|
if ($manufacturer !== '') {
|
|
$html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('manufacturer_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
|
|
}
|
|
|
|
$html .= '</dl>'
|
|
. '<div class="retriex-product-card__relevance"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('relevance_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
|
. htmlspecialchars($relevance, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
. '</div>'
|
|
. '</article>';
|
|
|
|
return $html;
|
|
}
|
|
|
|
private function formatProductAvailability(?bool $available): string
|
|
{
|
|
return match ($available) {
|
|
true => $this->agentRunnerConfig->getProductionUiText('availability_yes'),
|
|
false => $this->agentRunnerConfig->getProductionUiText('availability_no'),
|
|
default => $this->agentRunnerConfig->getProductionUiText('availability_unknown'),
|
|
};
|
|
}
|
|
|
|
private function buildProductRelevanceLabel(ShopProductResult $product, string $query): string
|
|
{
|
|
$matchedQueries = [];
|
|
|
|
foreach ($product->matchedQueries as $matchedQuery) {
|
|
$matchedQuery = $this->normalizeOneLine((string) $matchedQuery);
|
|
|
|
if ($matchedQuery !== '' && !in_array($matchedQuery, $matchedQueries, true)) {
|
|
$matchedQueries[] = $matchedQuery;
|
|
}
|
|
}
|
|
|
|
if ($matchedQueries !== []) {
|
|
return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_matched_queries'), [
|
|
'queries' => implode(', ', array_slice($matchedQueries, 0, 3)),
|
|
]);
|
|
}
|
|
|
|
foreach ($product->highlights as $highlight) {
|
|
$highlight = $this->normalizeOneLine($this->plainTextFromHtml((string) $highlight));
|
|
|
|
if ($highlight !== '') {
|
|
return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_highlight'), [
|
|
'highlight' => mb_substr($highlight, 0, 140, 'UTF-8'),
|
|
]);
|
|
}
|
|
}
|
|
|
|
$matchSource = $this->normalizeOneLine((string) $product->matchSource);
|
|
|
|
if ($matchSource !== '') {
|
|
return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_match_source'), ['source' => $matchSource]);
|
|
}
|
|
|
|
if ($query !== '') {
|
|
return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_query'), ['query' => $query]);
|
|
}
|
|
|
|
return $this->agentRunnerConfig->getProductionUiTemplate('relevance_default');
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
*/
|
|
private function buildFollowUpActionsMessage(
|
|
bool $isCommerceIntent,
|
|
bool $hasShopResults,
|
|
bool $hasKnowledge,
|
|
bool $shopSearchHadSystemFailure,
|
|
array $shopResults,
|
|
string $shopSearchQuery,
|
|
string $answerText
|
|
): string {
|
|
if (!$this->agentRunnerConfig->isProductionUiFollowUpActionsEnabled()) {
|
|
return '';
|
|
}
|
|
|
|
$context = $this->buildFollowUpActionContext(
|
|
shopResults: $shopResults,
|
|
shopSearchQuery: $shopSearchQuery,
|
|
answerText: $answerText
|
|
);
|
|
|
|
$actions = [];
|
|
$seenActionKeys = [];
|
|
|
|
if ($hasShopResults) {
|
|
$this->appendFollowUpActions(
|
|
actions: $actions,
|
|
seenActionKeys: $seenActionKeys,
|
|
items: $this->agentRunnerConfig->getProductionUiFollowUpActions('shop_results'),
|
|
context: $context
|
|
);
|
|
} elseif ($isCommerceIntent && !$shopSearchHadSystemFailure && $context['shop_query'] !== '') {
|
|
$this->appendFollowUpActions(
|
|
actions: $actions,
|
|
seenActionKeys: $seenActionKeys,
|
|
items: $this->agentRunnerConfig->getProductionUiFollowUpActions('commerce'),
|
|
context: $context
|
|
);
|
|
}
|
|
|
|
if ($hasKnowledge) {
|
|
$this->appendFollowUpActions(
|
|
actions: $actions,
|
|
seenActionKeys: $seenActionKeys,
|
|
items: $this->agentRunnerConfig->getProductionUiFollowUpActions('knowledge'),
|
|
context: $context
|
|
);
|
|
}
|
|
|
|
if ($actions === []) {
|
|
return '';
|
|
}
|
|
|
|
$html = '<div class="retriex-action-card retriex-followup-actions" data-retriex-action-card-id="followup-actions" data-retriex-action-card-state="completed">'
|
|
. '<div class="retriex-meta-card__eyebrow">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('followup_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
|
|
. '<div class="retriex-meta-card__title">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('followup_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
|
|
. '<div class="retriex-action-chip-row">';
|
|
|
|
foreach ($actions as $action) {
|
|
$html .= '<button type="button" class="retriex-action-chip" data-retriex-action-prompt="'
|
|
. htmlspecialchars($action['prompt'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
. '">'
|
|
. htmlspecialchars($action['label'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
. '</button>';
|
|
}
|
|
|
|
$html .= '</div></div>';
|
|
|
|
return $html;
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
* @return array{shop_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array<string,int>}
|
|
*/
|
|
private function buildFollowUpActionContext(array $shopResults, string $shopSearchQuery, string $answerText): array
|
|
{
|
|
$plainAnswerText = $this->normalizeOneLine($this->plainTextFromHtml($answerText));
|
|
$roleCounts = $this->buildFollowUpActionRoleCounts($shopResults);
|
|
$displayedRoleCounts = $this->buildFollowUpActionDisplayedRoleCounts($shopResults, $plainAnswerText);
|
|
|
|
if (array_sum($displayedRoleCounts) > 0) {
|
|
$roleCounts = $displayedRoleCounts;
|
|
}
|
|
|
|
return [
|
|
'shop_query' => $this->normalizeOneLine($shopSearchQuery),
|
|
'answer_has_price' => $this->followUpActionAnswerAlreadyContainsPrice($plainAnswerText),
|
|
'answer_text' => $plainAnswerText,
|
|
'answer_anchor' => $this->buildFollowUpActionAnswerAnchor($plainAnswerText),
|
|
'answer_detail_score' => $this->calculateFollowUpActionAnswerDetailScore($plainAnswerText),
|
|
'role_counts' => $roleCounts,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private function emptyFollowUpActionRoleCounts(): array
|
|
{
|
|
return [
|
|
ProductRoleResolver::ROLE_MAIN_DEVICE => 0,
|
|
ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE => 0,
|
|
ProductRoleResolver::ROLE_AMBIGUOUS_MIXED => 0,
|
|
ProductRoleResolver::ROLE_UNKNOWN => 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
* @return array<string, int>
|
|
*/
|
|
private function buildFollowUpActionRoleCounts(array $shopResults): array
|
|
{
|
|
$roleCounts = $this->emptyFollowUpActionRoleCounts();
|
|
|
|
foreach ($shopResults as $product) {
|
|
if (!$product instanceof ShopProductResult) {
|
|
continue;
|
|
}
|
|
|
|
$this->countFollowUpActionProductRole($roleCounts, $product);
|
|
}
|
|
|
|
return $roleCounts;
|
|
}
|
|
|
|
/**
|
|
* @param ShopProductResult[] $shopResults
|
|
* @return array<string, int>
|
|
*/
|
|
private function buildFollowUpActionDisplayedRoleCounts(array $shopResults, string $answerText): array
|
|
{
|
|
$roleCounts = $this->emptyFollowUpActionRoleCounts();
|
|
if ($answerText === '') {
|
|
return $roleCounts;
|
|
}
|
|
|
|
foreach ($shopResults as $product) {
|
|
if (!$product instanceof ShopProductResult) {
|
|
continue;
|
|
}
|
|
|
|
if (!$this->isFollowUpActionProductDisplayedInAnswer($product, $answerText)) {
|
|
continue;
|
|
}
|
|
|
|
$this->countFollowUpActionProductRole($roleCounts, $product);
|
|
}
|
|
|
|
return $roleCounts;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $roleCounts
|
|
*/
|
|
private function countFollowUpActionProductRole(array &$roleCounts, ShopProductResult $product): void
|
|
{
|
|
$role = $this->resolveFollowUpActionShopProductRole($product);
|
|
if (!array_key_exists($role, $roleCounts)) {
|
|
$role = ProductRoleResolver::ROLE_UNKNOWN;
|
|
}
|
|
|
|
++$roleCounts[$role];
|
|
}
|
|
|
|
private function isFollowUpActionProductDisplayedInAnswer(ShopProductResult $product, string $answerText): bool
|
|
{
|
|
$normalizedAnswer = mb_strtolower($this->normalizeOneLine($answerText), 'UTF-8');
|
|
if ($normalizedAnswer === '') {
|
|
return false;
|
|
}
|
|
|
|
$productNumber = $this->normalizeOneLine((string) $product->productNumber);
|
|
if ($productNumber !== '' && mb_strlen($productNumber, 'UTF-8') >= 3 && str_contains($normalizedAnswer, mb_strtolower($productNumber, 'UTF-8'))) {
|
|
return true;
|
|
}
|
|
|
|
$productName = mb_strtolower($this->normalizeOneLine($product->name), 'UTF-8');
|
|
if ($productName === '' || mb_strlen($productName, 'UTF-8') < 16) {
|
|
return false;
|
|
}
|
|
|
|
preg_match_all('/[\p{L}\p{N}]+/u', $productName, $matches);
|
|
$tokens = array_values(array_unique($matches[0] ?? []));
|
|
if (count($tokens) < 3) {
|
|
return false;
|
|
}
|
|
|
|
return str_contains($normalizedAnswer, $productName);
|
|
}
|
|
|
|
private function buildFollowUpActionAnswerAnchor(string $answerText): string
|
|
{
|
|
$anchors = [];
|
|
|
|
$modelAnchor = $this->referenceAnchorExtractor->extractFirstProductModelAnchor($answerText);
|
|
if ($modelAnchor !== '') {
|
|
$anchors[] = $modelAnchor;
|
|
}
|
|
|
|
$measurementAnchor = $this->referenceAnchorExtractor->extractFirstMeasurementValueAnchor($answerText);
|
|
if ($measurementAnchor !== '') {
|
|
$anchors[] = $measurementAnchor;
|
|
}
|
|
|
|
return $this->normalizeOneLine(implode(' ', array_values(array_unique($anchors))));
|
|
}
|
|
|
|
private function calculateFollowUpActionAnswerDetailScore(string $answerText): int
|
|
{
|
|
$score = 0;
|
|
|
|
if ($this->referenceAnchorExtractor->extractFirstProductModelAnchor($answerText) !== '') {
|
|
++$score;
|
|
}
|
|
|
|
if ($this->referenceAnchorExtractor->extractFirstMeasurementValueAnchor($answerText) !== '') {
|
|
++$score;
|
|
}
|
|
|
|
return $score;
|
|
}
|
|
|
|
private function resolveFollowUpActionShopProductRole(ShopProductResult $product): string
|
|
{
|
|
$primaryText = mb_strtolower($this->normalizeOneLine(implode(' ', [
|
|
$product->name,
|
|
(string) $product->productNumber,
|
|
])), 'UTF-8');
|
|
|
|
$hasPrimaryAccessory = $this->containsAnyConfiguredTerm($primaryText, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords());
|
|
$hasPrimaryDevice = $this->containsAnyConfiguredTerm($primaryText, $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords());
|
|
|
|
if ($hasPrimaryAccessory && !$hasPrimaryDevice) {
|
|
return ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE;
|
|
}
|
|
|
|
if ($hasPrimaryDevice && !$hasPrimaryAccessory) {
|
|
return ProductRoleResolver::ROLE_MAIN_DEVICE;
|
|
}
|
|
|
|
if ($hasPrimaryAccessory && $hasPrimaryDevice) {
|
|
return ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE;
|
|
}
|
|
|
|
$corpus = mb_strtolower($this->normalizeOneLine(implode(' ', [
|
|
$product->name,
|
|
(string) $product->description,
|
|
(string) $product->customFields,
|
|
implode(' ', $product->highlights),
|
|
])), 'UTF-8');
|
|
|
|
$hasAccessory = $this->containsAnyConfiguredTerm($corpus, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords());
|
|
$hasDevice = $this->containsAnyConfiguredTerm($corpus, $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords());
|
|
|
|
if ($hasAccessory && !$hasDevice) {
|
|
return ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE;
|
|
}
|
|
|
|
if ($hasDevice && !$hasAccessory) {
|
|
return ProductRoleResolver::ROLE_MAIN_DEVICE;
|
|
}
|
|
|
|
if ($hasAccessory && $hasDevice) {
|
|
return ProductRoleResolver::ROLE_AMBIGUOUS_MIXED;
|
|
}
|
|
|
|
return ProductRoleResolver::ROLE_UNKNOWN;
|
|
}
|
|
|
|
private function followUpActionAnswerAlreadyContainsPrice(string $answerText): bool
|
|
{
|
|
return preg_match('/(?:\bpreis\b.{0,24}\d+[,.]\d{2}|\d+[,.]\d{2}\s*(?:€|eur)\b|(?:€|eur)\s*\d+[,.]\d{2})/iu', $answerText) === 1;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $actions
|
|
* @param array<string, bool> $seenActionKeys
|
|
* @param array<int, array<string, mixed>> $items
|
|
* @param array{shop_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array<string,int>} $context
|
|
*/
|
|
private function appendFollowUpActions(array &$actions, array &$seenActionKeys, array $items, array $context): void
|
|
{
|
|
foreach ($items as $item) {
|
|
$label = trim((string) ($item['label'] ?? ''));
|
|
$actionPrompt = trim((string) ($item['prompt'] ?? ''));
|
|
|
|
if ($label === '' || $actionPrompt === '') {
|
|
continue;
|
|
}
|
|
|
|
if (!$this->shouldShowFollowUpAction($item, $context)) {
|
|
continue;
|
|
}
|
|
|
|
$actionPrompt = $this->renderFollowUpActionPrompt($actionPrompt, $context);
|
|
if ($actionPrompt === '') {
|
|
continue;
|
|
}
|
|
|
|
$key = mb_strtolower($label . "\n" . $actionPrompt, 'UTF-8');
|
|
if (isset($seenActionKeys[$key])) {
|
|
continue;
|
|
}
|
|
|
|
$seenActionKeys[$key] = true;
|
|
$actions[] = [
|
|
'label' => $label,
|
|
'prompt' => $actionPrompt,
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $item
|
|
* @param array{shop_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array<string,int>} $context
|
|
*/
|
|
private function shouldShowFollowUpAction(array $item, array $context): bool
|
|
{
|
|
$actionType = isset($item['action_type']) && is_scalar($item['action_type']) ? trim((string) $item['action_type']) : '';
|
|
$targetRole = isset($item['target_role']) && is_scalar($item['target_role']) ? trim((string) $item['target_role']) : '';
|
|
|
|
if ($this->followUpActionAnswerMatchesAnyConfiguredPattern($context['answer_text'], $item['hide_when_answer_matches_any'] ?? [])) {
|
|
return false;
|
|
}
|
|
|
|
$hideAtDetailScore = $this->optionalFollowUpActionInt($item, 'hide_when_answer_detail_score_at_least');
|
|
if ($hideAtDetailScore !== null && $context['answer_detail_score'] >= $hideAtDetailScore) {
|
|
return false;
|
|
}
|
|
|
|
if ($this->optionalFollowUpActionBool($item, 'requires_answer_anchor') && $context['answer_anchor'] === '') {
|
|
return false;
|
|
}
|
|
|
|
if ($actionType === 'shop_search') {
|
|
return $context['shop_query'] !== '';
|
|
}
|
|
|
|
if ($actionType === 'price_details') {
|
|
return $context['shop_query'] !== '' && !$context['answer_has_price'];
|
|
}
|
|
|
|
if ($actionType === 'role_filter') {
|
|
return $context['shop_query'] !== '' && $this->isFollowUpRoleFilterMeaningful($targetRole, $context['role_counts']);
|
|
}
|
|
|
|
if ($actionType === 'technical_details') {
|
|
return $context['answer_anchor'] !== '';
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function optionalFollowUpActionBool(array $item, string $key): bool
|
|
{
|
|
if (!array_key_exists($key, $item)) {
|
|
return false;
|
|
}
|
|
|
|
$value = $item[$key];
|
|
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (is_scalar($value)) {
|
|
$normalized = mb_strtolower(trim((string) $value), 'UTF-8');
|
|
|
|
return in_array($normalized, ['1', 'true', 'yes', 'on'], true);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function optionalFollowUpActionInt(array $item, string $key): ?int
|
|
{
|
|
if (!isset($item[$key]) || !is_scalar($item[$key])) {
|
|
return null;
|
|
}
|
|
|
|
$value = trim((string) $item[$key]);
|
|
if ($value === '' || !preg_match('/^-?\d+$/', $value)) {
|
|
return null;
|
|
}
|
|
|
|
return (int) $value;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $patterns
|
|
*/
|
|
private function followUpActionAnswerMatchesAnyConfiguredPattern(string $answerText, mixed $patterns): bool
|
|
{
|
|
if (!is_array($patterns) || $answerText === '') {
|
|
return false;
|
|
}
|
|
|
|
foreach ($patterns as $pattern) {
|
|
if (!is_scalar($pattern)) {
|
|
continue;
|
|
}
|
|
|
|
$pattern = trim((string) $pattern);
|
|
if ($pattern === '') {
|
|
continue;
|
|
}
|
|
|
|
if (@preg_match($pattern, '') === false) {
|
|
continue;
|
|
}
|
|
|
|
if (@preg_match($pattern, $answerText) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $roleCounts
|
|
*/
|
|
private function isFollowUpRoleFilterMeaningful(string $targetRole, array $roleCounts): bool
|
|
{
|
|
if (!isset($roleCounts[$targetRole]) || $roleCounts[$targetRole] <= 0) {
|
|
return false;
|
|
}
|
|
|
|
$knownRoleTotal = ($roleCounts[ProductRoleResolver::ROLE_MAIN_DEVICE] ?? 0)
|
|
+ ($roleCounts[ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE] ?? 0);
|
|
|
|
if ($knownRoleTotal <= 0) {
|
|
return false;
|
|
}
|
|
|
|
return $roleCounts[$targetRole] < $knownRoleTotal;
|
|
}
|
|
|
|
/**
|
|
* @param array{shop_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array<string,int>} $context
|
|
*/
|
|
private function renderFollowUpActionPrompt(string $prompt, array $context): string
|
|
{
|
|
$rendered = strtr($prompt, [
|
|
'{shop_query}' => $context['shop_query'],
|
|
'{answer_anchor}' => $context['answer_anchor'],
|
|
]);
|
|
|
|
return $this->normalizeOneLine($rendered);
|
|
}
|
|
|
|
private function buildShopSearchMetaMessage(
|
|
string $query,
|
|
string $commerceIntent,
|
|
bool $usedOptimizedQuery,
|
|
string $originalQuery,
|
|
?int $resultCount = null,
|
|
bool $completed = false,
|
|
bool $attemptedRepair = false,
|
|
bool $usedRepair = false,
|
|
bool $unavailable = false
|
|
): string {
|
|
$query = $this->normalizeOneLine($query);
|
|
$originalQuery = $this->normalizeOneLine($originalQuery);
|
|
|
|
if ($query === '') {
|
|
$query = $originalQuery !== '' ? $originalQuery : $this->agentRunnerConfig->getProductionUiText('shop_meta_fallback_query');
|
|
}
|
|
|
|
$queryModeLabel = $usedOptimizedQuery ? $this->agentRunnerConfig->getProductionUiText('shop_meta_query_mode_optimized') : $this->agentRunnerConfig->getProductionUiText('shop_meta_query_mode_direct');
|
|
$intentLabel = $commerceIntent !== '' ? $commerceIntent : $this->agentRunnerConfig->getProductionUiText('shop_meta_default_intent');
|
|
$title = $unavailable
|
|
? $this->agentRunnerConfig->getProductionUiText('shop_meta_title_unavailable')
|
|
: ($completed
|
|
? $this->agentRunnerConfig->getProductionUiText('shop_meta_title_completed')
|
|
: $this->agentRunnerConfig->getProductionUiText('shop_meta_title_running'));
|
|
$statusLabel = $completed
|
|
? $this->agentRunnerConfig->getProductionUiText('shop_meta_status_completed')
|
|
: $this->agentRunnerConfig->getProductionUiText('shop_meta_status_running');
|
|
$resultLabel = $unavailable
|
|
? $this->agentRunnerConfig->getProductionUiText('shop_meta_result_unavailable')
|
|
: ($resultCount === null
|
|
? $this->agentRunnerConfig->getProductionUiText('shop_meta_result_loading')
|
|
: $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_meta_result_count'), ['count' => (string) max(0, $resultCount)]));
|
|
$state = $completed ? 'completed' : 'running';
|
|
$resultCountAttribute = $resultCount === null ? '' : (string) max(0, $resultCount);
|
|
$repairLabel = '';
|
|
|
|
if ($usedRepair) {
|
|
$repairLabel = $this->agentRunnerConfig->getProductionUiText('shop_meta_repair_used');
|
|
} elseif ($attemptedRepair) {
|
|
$repairLabel = $this->agentRunnerConfig->getProductionUiText('shop_meta_repair_checked');
|
|
}
|
|
|
|
$html = '<div class="retriex-meta-card retriex-shop-meta" data-retriex-meta-id="shop-search" data-retriex-meta-state="'
|
|
. htmlspecialchars($state, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
. '" data-retriex-shop-result-count="'
|
|
. htmlspecialchars($resultCountAttribute, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
. '">'
|
|
. '<div class="retriex-meta-card__eyebrow">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
|
|
. '<div class="retriex-meta-card__title">' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
|
|
. '<div class="retriex-meta-card__body">'
|
|
. '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($resultLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
|
. '<span class="retriex-meta-pill">' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
|
. '<span class="retriex-meta-pill">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($queryModeLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
|
. '<span class="retriex-meta-pill">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_intent_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($intentLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';
|
|
|
|
if ($repairLabel !== '') {
|
|
$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>';
|
|
|
|
return $html;
|
|
}
|
|
|
|
private function buildShopUnavailableMessage(?string $reason): string
|
|
{
|
|
$reason = $this->normalizeOneLine((string) $reason);
|
|
|
|
if ($reason === '') {
|
|
$reason = $this->agentRunnerConfig->getProductionUiText('shop_unavailable_default_reason');
|
|
}
|
|
|
|
if (mb_strlen($reason, 'UTF-8') > 320) {
|
|
$reason = rtrim(mb_substr($reason, 0, 317, 'UTF-8')) . '...';
|
|
}
|
|
|
|
return '<div class="retriex-alert retriex-alert--warning">'
|
|
. '<div class="retriex-alert__icon">⚠️</div>'
|
|
. '<div class="retriex-alert__content">'
|
|
. '<div class="retriex-alert__title">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_unavailable_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
|
|
. '<div class="retriex-alert__text">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_unavailable_text_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''
|
|
. htmlspecialchars($reason, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
. '</div>'
|
|
. '</div>'
|
|
. '</div>';
|
|
}
|
|
|
|
|
|
/**
|
|
* @param array<string, string> $values
|
|
*/
|
|
private function renderAgentTemplate(string $template, array $values): string
|
|
{
|
|
foreach ($values as $key => $value) {
|
|
$template = str_replace('{' . $key . '}', $value, $template);
|
|
}
|
|
|
|
return $template;
|
|
}
|
|
|
|
private function normalizeOneLine(string $value): string
|
|
{
|
|
$value = trim($value);
|
|
|
|
return preg_replace('/\s+/u', ' ', $value) ?? $value;
|
|
}
|
|
|
|
|
|
private function buildUserErrorMessage(Throwable $e): string
|
|
{
|
|
$message = trim($e->getMessage());
|
|
|
|
if ($message === '') {
|
|
$message = $e::class;
|
|
}
|
|
|
|
$safeMessage = htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
|
|
if (!$this->debug) {
|
|
return $this->agentRunnerConfig->getGenericInternalErrorMessage()
|
|
. $this->renderAgentTemplate(
|
|
$this->agentRunnerConfig->getTechnicalErrorDetailTemplate(),
|
|
['message' => $safeMessage]
|
|
);
|
|
}
|
|
|
|
return $this->agentRunnerConfig->getDebugInternalErrorPrefix()
|
|
. $safeMessage;
|
|
}
|
|
|
|
private function badge(string $label): string
|
|
{
|
|
return sprintf(
|
|
$this->agentRunnerConfig->getSourceBadgeHtmlTemplate(),
|
|
htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
);
|
|
}
|
|
|
|
private function systemMsg(string $msg, string $type = ''): string
|
|
{
|
|
if (!$this->systemMsgOn) {
|
|
return '';
|
|
}
|
|
|
|
return match ($type) {
|
|
'answer' => $msg,
|
|
'err' => sprintf($this->agentRunnerConfig->getErrorHtmlTemplate(), $msg),
|
|
'think' => sprintf($this->agentRunnerConfig->getThinkHtmlTemplate(), $msg),
|
|
'info' => sprintf($this->agentRunnerConfig->getInfoHtmlTemplate(), $msg),
|
|
'meta' => $msg,
|
|
'debug' => sprintf(
|
|
$this->agentRunnerConfig->getDebugHtmlTemplate(),
|
|
htmlspecialchars($msg, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
|
),
|
|
default => $msg,
|
|
};
|
|
}
|
|
}
|