Files
MtoRagSystem/src/Agent/AgentRunner.php
2026-05-06 10:53:54 +02:00

4286 lines
157 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Agent;
use App\Commerce\Dto\ShopProductResult;
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 = '';
}
$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 = '';
}
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
);
}
}
$shopResults = $repairPayload['results'];
$shopResults = $this->guardDirectProductShopResults($prompt, $shopSearchQuery, $shopResults);
$shopResults = $this->sortShopResultsForLengthRequest($prompt, $shopSearchQuery, $shopResults);
$attemptedShopRepair = $repairPayload['attemptedRepair'];
$usedShopRepair = $repairPayload['usedRepair'];
$shopRepairQueries = $repairPayload['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'
);
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 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 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
{
$latest = '';
foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentPatterns() as $pattern) {
if (@preg_match_all($pattern, $commerceHistoryContext, $matches, PREG_SET_ORDER) === false) {
continue;
}
foreach ($matches as $match) {
$candidate = trim((string) ($match[0] ?? ''));
if ($candidate !== '') {
$latest = $candidate;
}
}
}
return $latest;
}
private function normalizeShopQueryAnchor(string $anchor): string
{
$anchor = str_replace('®', '', $anchor);
$anchor = mb_strtolower(trim($anchor), 'UTF-8');
$anchor = preg_replace('/[^\p{L}\p{N},.%°+\-\s]+/u', ' ', $anchor) ?? $anchor;
$anchor = preg_replace('/\s+/u', ' ', $anchor) ?? $anchor;
return trim($anchor);
}
private function queryAlreadyContainsAllAnchorTokens(string $query, string $anchor): bool
{
$queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($query), true);
foreach ($this->tokenizeShopQueryCandidate($anchor) as $token) {
if (!isset($queryTokens[$token])) {
return false;
}
}
return true;
}
private function preserveOptimizedShopQueryLanguage(string $query, string $sourcePrompt): string
{
if (!$this->agentRunnerConfig->isShopQueryLanguagePreservationEnabled()) {
return $query;
}
$language = $this->detectConfiguredShopQueryLanguage($sourcePrompt);
if ($language === null) {
return $query;
}
$replacements = $this->agentRunnerConfig->getShopQueryTranslationReplacements($language);
if ($replacements === []) {
return $query;
}
foreach ($replacements as $source => $target) {
$pattern = '/(?<![\\p{L}\\p{N}])' . preg_replace('/\\s+/u', '\\s+', preg_quote($source, '/')) . '(?![\\p{L}\\p{N}])/iu';
$query = preg_replace($pattern, $target, $query) ?? $query;
}
return $query;
}
private function detectConfiguredShopQueryLanguage(string $sourcePrompt): ?string
{
$normalized = ' ' . strtolower($sourcePrompt) . ' ';
$normalized = preg_replace('/[\\r\\n\\t]+/u', ' ', $normalized) ?? $normalized;
$normalized = preg_replace('/\\s+/u', ' ', $normalized) ?? $normalized;
foreach ($this->agentRunnerConfig->getShopQueryLanguageMarkers() as $language => $markers) {
foreach ($markers as $marker) {
if ($marker !== '' && str_contains($normalized, $marker)) {
return $language;
}
}
}
return null;
}
/**
* @return Generator<int, string, mixed, string>
*/
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;
}
$filtered = [];
foreach ($shopResults as $product) {
if (!$product instanceof ShopProductResult) {
continue;
}
if ($this->shopProductMatchesAnyDirectProductTerm($product, $requestedTerms)) {
$filtered[] = $product;
}
}
return $filtered;
}
/**
* @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;
}
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;
}
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 shopProductMatchesAnyDirectProductTerm(ShopProductResult $product, array $requestedTerms): bool
{
$productText = trim(implode(' ', array_filter([
$product->name,
$product->description,
implode(' ', $product->highlights),
$product->customFields,
])));
foreach ($requestedTerms as $term) {
if ($this->containsAllShopQueryTokens($productText, $term)) {
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 '';
}
if ($shopResults === []) {
return $this->agentRunnerConfig->getDirectShopResultAnswerNoResultsMessage();
}
$lines = [$this->agentRunnerConfig->getDirectShopResultAnswerIntro()];
if ($this->isShopResultLengthSortRequested($prompt . ' ' . $shopSearchQuery)) {
$note = trim($this->agentRunnerConfig->getDirectShopResultAnswerSortedByLengthNote());
if ($note !== '') {
$lines[] = $note;
}
}
$lines[] = '';
foreach ($this->buildDirectShopProductLines($shopResults, '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[] = 'Systemhinweis: ' . $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');
}
private function buildFollowUpActionsMessage(bool $isCommerceIntent, bool $hasShopResults, bool $hasKnowledge): string
{
if (!$isCommerceIntent && !$hasShopResults && !$hasKnowledge) {
return '';
}
$actions = [];
if ($isCommerceIntent || $hasShopResults) {
$actions = array_merge($actions, $this->agentRunnerConfig->getProductionUiFollowUpActions('commerce'));
}
if ($hasKnowledge || $hasShopResults) {
$actions = array_merge($actions, $this->agentRunnerConfig->getProductionUiFollowUpActions('knowledge'));
}
if ($actions === []) {
return '';
}
$html = '<div class="retriex-meta-card retriex-followup-actions" data-retriex-meta-id="followup-actions" data-retriex-meta-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) {
$label = (string) ($action['label'] ?? '');
$actionPrompt = (string) ($action['prompt'] ?? '');
if ($label === '' || $actionPrompt === '') {
continue;
}
$html .= '<button type="button" class="retriex-action-chip" data-retriex-action-prompt="'
. htmlspecialchars($actionPrompt, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '">'
. htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</button>';
}
$html .= '</div></div>';
return $html;
}
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()
. '<br><small>Technischer Fehler: ' . $safeMessage . '</small>';
}
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,
};
}
}