3243 lines
116 KiB
PHP
3243 lines
116 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\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 CommerceIntentLite $commerceIntentLite,
|
||
private OllamaClient $ollamaClient,
|
||
private LoggerInterface $agentLogger,
|
||
private AgentRunnerConfig $agentRunnerConfig,
|
||
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: 'Antwort wird vorbereitet',
|
||
ragCount: null,
|
||
shopCount: null,
|
||
shopCountMode: $this->resolveShopCountModeForMeta(
|
||
commerceIntent: $commerceIntent,
|
||
shopSearchAttempted: $shopSearchAttempted,
|
||
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
||
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
|
||
),
|
||
sourceLabels: $sources,
|
||
confidenceLabel: 'Beleglage wird geprüft'
|
||
),
|
||
'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: 'Shop-Routing erkannt',
|
||
ragCount: null,
|
||
shopCount: null,
|
||
shopCountMode: $this->resolveShopCountModeForMeta(
|
||
commerceIntent: $commerceIntent,
|
||
shopSearchAttempted: $shopSearchAttempted,
|
||
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
||
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
|
||
),
|
||
sourceLabels: $sources,
|
||
confidenceLabel: 'Shopdaten werden geprüft'
|
||
),
|
||
'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: 'RAG-Wissen wurde durchsucht',
|
||
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: 'Shop-Suche wird vorbereitet',
|
||
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);
|
||
$shopQueryHistoryContext = $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),
|
||
]);
|
||
}
|
||
|
||
if ($this->shouldUseDeterministicStandaloneShopQuery($originalPrompt, $shopQueryHistoryContext)) {
|
||
$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 = '';
|
||
}
|
||
|
||
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: 'Mehr Kontext nötig',
|
||
ragCount: count($knowledgeChunks),
|
||
shopCount: null,
|
||
shopCountMode: $this->resolveShopCountModeForMeta(
|
||
commerceIntent: $commerceIntent,
|
||
shopSearchAttempted: $shopSearchAttempted,
|
||
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
||
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
|
||
),
|
||
sourceLabels: $sources,
|
||
confidenceLabel: 'mehr Kontext nötig',
|
||
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: 'Shop wird durchsucht',
|
||
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(
|
||
'Shopdaten konnten nicht geladen werden',
|
||
$primaryShopSearchFailureReason
|
||
);
|
||
|
||
$repairPayload = [
|
||
'results' => $primaryShopResults,
|
||
'attemptedRepair' => false,
|
||
'usedRepair' => false,
|
||
'repairQueries' => [],
|
||
];
|
||
} else {
|
||
yield $this->systemMsg('Erweiterte Shopsuche wird geprüft…', 'think');
|
||
|
||
$repairPayload = $this->repairShopResults(
|
||
prompt: $prompt,
|
||
userId: $userId,
|
||
commerceIntent: $commerceIntent,
|
||
commerceHistoryContext: $shopQueryHistoryContext,
|
||
primaryQuery: $shopSearchQuery,
|
||
primaryShopResults: $primaryShopResults,
|
||
knowledgeChunks: $knowledgeChunks
|
||
);
|
||
}
|
||
}
|
||
|
||
$shopResults = $repairPayload['results'];
|
||
$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 ? 'Shopdaten nicht verfügbar' : 'Shop-Suche abgeschlossen',
|
||
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: 'Antwort wird generiert',
|
||
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
|
||
);
|
||
|
||
$fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer);
|
||
|
||
yield $this->systemMsg(
|
||
$this->buildProductionUiMetaMessage(
|
||
stageLabel: 'Abgeschlossen',
|
||
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: 'Antwort wurde unterbrochen',
|
||
ragCount: count($knowledgeChunks),
|
||
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
|
||
shopCountMode: $this->resolveShopCountModeForMeta(
|
||
commerceIntent: $commerceIntent,
|
||
shopSearchAttempted: $shopSearchAttempted,
|
||
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
|
||
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
|
||
),
|
||
sourceLabels: $sources,
|
||
confidenceLabel: 'nicht abgeschlossen',
|
||
completed: true
|
||
),
|
||
'meta'
|
||
);
|
||
yield $this->systemMsg($userErrorMessage, 'err');
|
||
|
||
$historyResponse = $this->buildHistoryResponse('', array_merge(
|
||
$historyNotices,
|
||
[$this->buildHistoryNotice('Antwort konnte nicht abgeschlossen werden', $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 (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 = strtr($token, [
|
||
'ä' => 'ae',
|
||
'ö' => 'oe',
|
||
'ü' => 'ue',
|
||
'ß' => 'ss',
|
||
]);
|
||
$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 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) !== 1) {
|
||
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) !== 1) {
|
||
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->extractLatestAssistantReferenceAnchors($history);
|
||
|
||
if ($previousQuestions === [] && $referenceAnchors === []) {
|
||
return $prompt;
|
||
}
|
||
|
||
$lines = [];
|
||
|
||
foreach ($previousQuestions as $question) {
|
||
$lines[] = 'Vorherige Nutzerfrage: ' . $question;
|
||
}
|
||
|
||
if ($referenceAnchors !== []) {
|
||
$lines[] = 'Vorherige technische Referenzanker (nur zur Referenzauflösung, keine Faktenquelle): '
|
||
. implode(' ', $referenceAnchors);
|
||
}
|
||
|
||
$lines[] = 'Aktuelle Folgefrage: ' . $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) !== 1) {
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Extracts stable reference anchors from the latest assistant answer.
|
||
*
|
||
* These anchors are only used to resolve follow-up references such as
|
||
* "der Wert" or "welcher Indikator". They are not factual evidence for
|
||
* the final answer. To avoid propagating wrong earlier answers, only the
|
||
* first explicit Testomat model reference and the first explicit °dH value
|
||
* are kept. Indicator names, reagent codes, prices, URLs and product
|
||
* numbers are intentionally ignored here.
|
||
*
|
||
* @return string[]
|
||
*/
|
||
private function extractLatestAssistantReferenceAnchors(string $history): array
|
||
{
|
||
$turn = $this->extractLatestHistoryTurn($history);
|
||
|
||
if ($turn === '') {
|
||
return [];
|
||
}
|
||
|
||
$answer = preg_replace($this->agentRunnerConfig->getFollowUpHistoryQuestionStripPattern(), '', $turn, 1) ?? '';
|
||
$answer = trim($answer);
|
||
|
||
if ($answer === '') {
|
||
return [];
|
||
}
|
||
|
||
$anchors = [];
|
||
|
||
$model = $this->extractFirstTestomatModelAnchor($answer);
|
||
if ($model !== '') {
|
||
$anchors[] = $model;
|
||
}
|
||
|
||
$hardnessValue = $this->extractFirstHardnessValueAnchor($answer);
|
||
if ($hardnessValue !== '') {
|
||
$anchors[] = $hardnessValue;
|
||
}
|
||
|
||
return array_values(array_unique($anchors));
|
||
}
|
||
|
||
private function extractLatestHistoryTurn(string $history): string
|
||
{
|
||
$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 !== ''
|
||
));
|
||
|
||
if ($turns === []) {
|
||
return '';
|
||
}
|
||
|
||
return (string) end($turns);
|
||
}
|
||
|
||
/**
|
||
* @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 extractFirstTestomatModelAnchor(string $text): string
|
||
{
|
||
if (preg_match($this->agentRunnerConfig->getFollowUpReferenceAnchorTestomatModelPattern(), $text, $matches) !== 1) {
|
||
return '';
|
||
}
|
||
|
||
$value = $this->sanitizeHistoryQuestion(($matches[0] ?? ''));
|
||
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||
|
||
return trim(str_replace('®', '', $value));
|
||
}
|
||
|
||
private function extractFirstHardnessValueAnchor(string $text): string
|
||
{
|
||
if (preg_match($this->agentRunnerConfig->getFollowUpReferenceAnchorHardnessValuePattern(), $text, $matches) !== 1) {
|
||
return '';
|
||
}
|
||
|
||
$value = preg_replace('/\s+/u', ' ', ($matches[0] ?? '')) ?? '';
|
||
|
||
return trim($value);
|
||
}
|
||
|
||
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 = str_replace(['-', '/', '_'], ' ', $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('Shop-Suchanfrage wird optimiert…', '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->extractFirstTestomatModelAnchor($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 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->extractFirstTestomatModelAnchor($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);
|
||
|
||
if ($guardedQuery !== $shopSearchQuery) {
|
||
return $guardedQuery;
|
||
}
|
||
|
||
return $shopSearchQuery;
|
||
}
|
||
|
||
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->extractFirstTestomatModelAnchor($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 ($contextQuery !== '' && !$this->isMetaOnlyShopQuery($contextQuery)) {
|
||
return $contextQuery;
|
||
}
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
private function buildContextFallbackShopQuery(string $question): string
|
||
{
|
||
$tokens = $this->tokenizeShopQueryCandidate($question);
|
||
|
||
if ($tokens === []) {
|
||
return '';
|
||
}
|
||
|
||
$filterTerms = [];
|
||
|
||
foreach (array_merge(
|
||
$this->agentRunnerConfig->getShopQueryMetaOnlyTerms(),
|
||
$this->agentRunnerConfig->getShopQueryContextFallbackFilterTerms()
|
||
) as $term) {
|
||
foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
|
||
$filterTerms[$token] = true;
|
||
}
|
||
}
|
||
|
||
$maxTerms = max(1, $this->agentRunnerConfig->getShopQueryContextFallbackMaxTerms());
|
||
$out = [];
|
||
|
||
foreach ($tokens as $token) {
|
||
if (isset($filterTerms[$token])) {
|
||
continue;
|
||
}
|
||
|
||
if (in_array($token, $out, true)) {
|
||
continue;
|
||
}
|
||
|
||
$out[] = $token;
|
||
|
||
if (count($out) >= $maxTerms) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return implode(' ', $out);
|
||
}
|
||
|
||
/**
|
||
* @return string[]
|
||
*/
|
||
private function tokenizeShopQueryCandidate(string $value): array
|
||
{
|
||
$value = mb_strtolower(trim($value), 'UTF-8');
|
||
$value = str_replace(['-', '/', '_'], ' ', $value);
|
||
|
||
if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', $value, $matches) !== 1) {
|
||
return [];
|
||
}
|
||
|
||
return array_values(array_filter(
|
||
array_map(static fn(string $token): string => trim($token), $matches[0] ?? []),
|
||
static fn(string $token): bool => $token !== ''
|
||
));
|
||
}
|
||
|
||
private function isMetaOnlyShopQuery(string $query): bool
|
||
{
|
||
if (!$this->agentRunnerConfig->isShopQueryMetaGuardEnabled()) {
|
||
return false;
|
||
}
|
||
|
||
$tokens = $this->tokenizeMetaGuardText($query);
|
||
|
||
if ($tokens === []) {
|
||
return true;
|
||
}
|
||
|
||
$metaTerms = [];
|
||
foreach ($this->agentRunnerConfig->getShopQueryMetaOnlyTerms() as $term) {
|
||
foreach ($this->tokenizeMetaGuardText($term) as $token) {
|
||
$metaTerms[$token] = true;
|
||
}
|
||
}
|
||
|
||
if ($metaTerms === []) {
|
||
return false;
|
||
}
|
||
|
||
foreach ($tokens as $token) {
|
||
if (!isset($metaTerms[$token])) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* @return string[]
|
||
*/
|
||
private function tokenizeMetaGuardText(string $value): array
|
||
{
|
||
$value = mb_strtolower(trim($value), 'UTF-8');
|
||
$value = str_replace(['-', '/', '_'], ' ', $value);
|
||
$value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
|
||
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||
$value = trim($value);
|
||
|
||
if ($value === '') {
|
||
return [];
|
||
}
|
||
|
||
return array_values(array_filter(
|
||
explode(' ', $value),
|
||
static fn(string $token): bool => $token !== ''
|
||
));
|
||
}
|
||
|
||
private function searchShop(
|
||
string $query,
|
||
string $commerceIntent,
|
||
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 = str_replace(['{anchor}', '{query}'], [$anchor, $query], $template);
|
||
$enriched = preg_replace('/\s+/u', ' ', $enriched) ?? $enriched;
|
||
|
||
return trim($enriched) !== '' ? trim($enriched) : $query;
|
||
}
|
||
|
||
private function containsConfiguredShopQueryAnchorTrigger(string $text): bool
|
||
{
|
||
$tokens = $this->tokenizeShopQueryCandidate($text);
|
||
|
||
if ($tokens === []) {
|
||
return false;
|
||
}
|
||
|
||
$tokenSet = array_fill_keys($tokens, true);
|
||
|
||
foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTriggerTerms() as $term) {
|
||
foreach ($this->tokenizeShopQueryCandidate($term) as $termToken) {
|
||
if (isset($tokenSet[$termToken])) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function extractLatestConfiguredShopQueryContextAnchor(string $commerceHistoryContext): string
|
||
{
|
||
$latest = '';
|
||
|
||
foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentPatterns() as $pattern) {
|
||
if (@preg_match_all($pattern, $commerceHistoryContext, $matches, PREG_SET_ORDER) === false) {
|
||
continue;
|
||
}
|
||
|
||
foreach ($matches as $match) {
|
||
$candidate = trim((string) ($match[0] ?? ''));
|
||
if ($candidate !== '') {
|
||
$latest = $candidate;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $latest;
|
||
}
|
||
|
||
private function normalizeShopQueryAnchor(string $anchor): string
|
||
{
|
||
$anchor = str_replace('®', '', $anchor);
|
||
$anchor = mb_strtolower(trim($anchor), 'UTF-8');
|
||
$anchor = preg_replace('/[^\p{L}\p{N},.%°+\-\s]+/u', ' ', $anchor) ?? $anchor;
|
||
$anchor = preg_replace('/\s+/u', ' ', $anchor) ?? $anchor;
|
||
|
||
return trim($anchor);
|
||
}
|
||
|
||
private function queryAlreadyContainsAllAnchorTokens(string $query, string $anchor): bool
|
||
{
|
||
$queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($query), true);
|
||
|
||
foreach ($this->tokenizeShopQueryCandidate($anchor) as $token) {
|
||
if (!isset($queryTokens[$token])) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private function preserveOptimizedShopQueryLanguage(string $query, string $sourcePrompt): string
|
||
{
|
||
if (!$this->agentRunnerConfig->isShopQueryLanguagePreservationEnabled()) {
|
||
return $query;
|
||
}
|
||
|
||
$language = $this->detectConfiguredShopQueryLanguage($sourcePrompt);
|
||
|
||
if ($language === null) {
|
||
return $query;
|
||
}
|
||
|
||
$replacements = $this->agentRunnerConfig->getShopQueryTranslationReplacements($language);
|
||
|
||
if ($replacements === []) {
|
||
return $query;
|
||
}
|
||
|
||
foreach ($replacements as $source => $target) {
|
||
$pattern = '/(?<![\\p{L}\\p{N}])' . preg_replace('/\\s+/u', '\\s+', preg_quote($source, '/')) . '(?![\\p{L}\\p{N}])/iu';
|
||
$query = preg_replace($pattern, $target, $query) ?? $query;
|
||
}
|
||
|
||
return $query;
|
||
}
|
||
|
||
private function detectConfiguredShopQueryLanguage(string $sourcePrompt): ?string
|
||
{
|
||
$normalized = ' ' . strtolower($sourcePrompt) . ' ';
|
||
$normalized = preg_replace('/[\\r\\n\\t]+/u', ' ', $normalized) ?? $normalized;
|
||
$normalized = preg_replace('/\\s+/u', ' ', $normalized) ?? $normalized;
|
||
|
||
foreach ($this->agentRunnerConfig->getShopQueryLanguageMarkers() as $language => $markers) {
|
||
foreach ($markers as $marker) {
|
||
if ($marker !== '' && str_contains($normalized, $marker)) {
|
||
return $language;
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
/**
|
||
* @return Generator<int, string, mixed, string>
|
||
*/
|
||
private function streamFinalAnswer(string $finalPrompt, string $noLlmFallbackAnswer = ''): Generator
|
||
{
|
||
$fullOutput = '';
|
||
$thinkingNoticeShown = 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;
|
||
}
|
||
|
||
$fullOutput .= $cleanToken;
|
||
|
||
$chunk = $chunker->push($cleanToken);
|
||
if ($chunk !== null) {
|
||
yield $this->systemMsg($chunk, 'answer');
|
||
}
|
||
}
|
||
} 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;
|
||
}
|
||
|
||
$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;
|
||
}
|
||
|
||
/**
|
||
* 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 .= ' Ursache: ' . $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 ['- Es wurden Shop-Treffer übergeben, aber keine lesbaren Produktdaten gefunden.'];
|
||
}
|
||
|
||
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 : 'Unbenanntes Shop-Produkt';
|
||
|
||
if ($product->productNumber !== null && trim($product->productNumber) !== '') {
|
||
$parts[] = 'Art.-Nr. ' . $this->normalizeOneLine($product->productNumber);
|
||
}
|
||
|
||
if ($product->manufacturer !== null && trim($product->manufacturer) !== '') {
|
||
$parts[] = 'Hersteller: ' . $this->normalizeOneLine($product->manufacturer);
|
||
}
|
||
|
||
if ($product->price !== null && trim($product->price) !== '') {
|
||
$parts[] = 'Preis: ' . $this->normalizeOneLine($product->price);
|
||
}
|
||
|
||
if ($product->available !== null) {
|
||
$parts[] = 'Verfügbar: ' . ($product->available ? 'ja' : 'nein');
|
||
}
|
||
|
||
if ($product->url !== null && trim($product->url) !== '') {
|
||
$parts[] = 'URL: ' . $this->normalizeOneLine($product->url);
|
||
}
|
||
|
||
if ($requestedProductRole === 'main_device_or_system' && $productRole === 'accessory_or_consumable') {
|
||
$parts[] = 'Hinweis: Zubehör/Verbrauchsartikel; nicht als Messanlage/Gerät bestätigt';
|
||
}
|
||
|
||
return sprintf('%d. %s', $index, implode(' | ', $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' => 'fachlich belegt',
|
||
'aggregate_missing' => 'geprüfte Quellen, keine passende Zählinformation',
|
||
'weak' => 'RAG-Näherungstreffer, kein direkter Fachbeleg',
|
||
default => 'noch keine belastbaren Treffer',
|
||
};
|
||
}
|
||
|
||
private function resolveRagEvidenceShopCheckConfidenceLabel(string $knowledgeEvidenceState): string
|
||
{
|
||
return match ($knowledgeEvidenceState) {
|
||
'direct' => 'fachlich belegt; Shopdaten werden geprüft',
|
||
'aggregate_missing' => 'geprüfte Quellen ohne Zählinformation; Shopdaten werden geprüft',
|
||
'weak' => 'RAG-Näherungstreffer; Shopdaten werden geprüft',
|
||
default => 'Shopdaten werden geprüft',
|
||
};
|
||
}
|
||
|
||
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->agentRunnerConfig->getRagEvidenceStopTerms() 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 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 = str_replace(['‐', '‑', '‒', '–', '—'], '-', $value);
|
||
$value = strtr($value, [
|
||
'ä' => 'ae',
|
||
'ö' => 'oe',
|
||
'ü' => 'ue',
|
||
'ß' => 'ss',
|
||
]);
|
||
$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 = 'Es wurden keine Daten vom LLM empfangen.';
|
||
}
|
||
|
||
$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 = 'Systemhinweis';
|
||
}
|
||
|
||
if ($detail === '') {
|
||
return 'Systemhinweis: ' . $title . '.';
|
||
}
|
||
|
||
if (mb_strlen($detail, 'UTF-8') > 500) {
|
||
$detail = rtrim(mb_substr($detail, 0, 497, 'UTF-8')) . '...';
|
||
}
|
||
|
||
return 'Systemhinweis: ' . $title . '. Ursache: ' . $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
|
||
? 'RAG-Treffer: wird geprüft'
|
||
: 'RAG-Treffer: ' . max(0, $ragCount);
|
||
$shopLabel = match ($shopCountMode) {
|
||
'count' => 'Shop-Treffer: ' . max(0, (int) $shopCount),
|
||
'loading' => 'Shop-Treffer: wird geladen',
|
||
'unavailable' => 'Shop-Treffer: nicht verfügbar',
|
||
'not_resolved' => 'Shop-Treffer: keine Suchquery',
|
||
default => 'Shop-Treffer: nicht angefragt',
|
||
};
|
||
$statusLabel = $completed ? 'Status: abgeschlossen' : 'Status: läuft';
|
||
$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">RetrieX-Status</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">Beleglage: ' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
||
. '</div>';
|
||
|
||
if ($sources !== []) {
|
||
$html .= '<div class="retriex-source-overview"><span>Datenbasis</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 ? 'keine belastbare Datenbasis' : 'wird geprüft';
|
||
$html .= '<div class="retriex-source-overview"><span>Datenbasis</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
|
||
? 'geprüfte Quellen ohne Zählinformation; Shopdaten nicht verfügbar'
|
||
: 'geprüfte Quellen, keine passende Zählinformation';
|
||
}
|
||
|
||
if ($shopSearchHadSystemFailure) {
|
||
return $hasKnowledge ? 'fachlich belegt; Shopdaten nicht verfügbar' : 'Shopdaten nicht verfügbar';
|
||
}
|
||
|
||
if ($hasKnowledge && $hasShopResults) {
|
||
return 'RAG + Shopdaten';
|
||
}
|
||
|
||
if (!$hasKnowledge && $hasShopResults) {
|
||
return 'nur Shopdaten';
|
||
}
|
||
|
||
if ($hasKnowledge && $shopSearchAttempted) {
|
||
return 'RAG-Wissen, keine Shop-Treffer';
|
||
}
|
||
|
||
if ($hasKnowledge) {
|
||
return 'fachlich belegt';
|
||
}
|
||
|
||
if ($isCommerceIntent || $shopSearchAttempted) {
|
||
return 'keine belastbaren Daten';
|
||
}
|
||
|
||
return 'noch keine belastbaren Treffer';
|
||
}
|
||
|
||
/**
|
||
* @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 = 'Live-Shopdaten';
|
||
}
|
||
|
||
if (!in_array($label, $labels, true)) {
|
||
$labels[] = $label;
|
||
}
|
||
}
|
||
|
||
return $labels;
|
||
}
|
||
|
||
/**
|
||
* @param ShopProductResult[] $shopResults
|
||
*/
|
||
private function buildShopProductCardsMessage(array $shopResults, string $query, bool $usedRepair): string
|
||
{
|
||
$maxCards = 5;
|
||
$visibleResults = array_slice($shopResults, 0, $maxCards);
|
||
$totalCount = count($shopResults);
|
||
$query = $this->normalizeOneLine($query);
|
||
$summary = $totalCount . ' Shop-Treffer ausgewertet';
|
||
|
||
if ($totalCount > $maxCards) {
|
||
$summary .= ' · Top ' . $maxCards . ' angezeigt';
|
||
}
|
||
|
||
if ($usedRepair) {
|
||
$summary .= ' · erweiterte Shopsuche genutzt';
|
||
}
|
||
|
||
$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">Shop-Ergebnisse</div>'
|
||
. '<div class="retriex-meta-card__title">Shop-Ergebnisse</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>Ausgewertete Suchquery</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) ?: 'Unbenanntes Produkt';
|
||
$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>Artikelnummer</dt><dd>' . htmlspecialchars($productNumber !== '' ? $productNumber : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
|
||
$html .= '<div><dt>Preis</dt><dd>' . htmlspecialchars($price !== '' ? $price : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
|
||
$html .= '<div><dt>Verfügbarkeit</dt><dd>' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
|
||
|
||
if ($manufacturer !== '') {
|
||
$html .= '<div><dt>Hersteller</dt><dd>' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
|
||
}
|
||
|
||
$html .= '</dl>'
|
||
. '<div class="retriex-product-card__relevance"><span>Relevanz</span>'
|
||
. htmlspecialchars($relevance, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||
. '</div>'
|
||
. '</article>';
|
||
|
||
return $html;
|
||
}
|
||
|
||
private function formatProductAvailability(?bool $available): string
|
||
{
|
||
return match ($available) {
|
||
true => 'verfügbar',
|
||
false => 'nicht verfügbar',
|
||
default => 'Shopstatus nicht übermittelt',
|
||
};
|
||
}
|
||
|
||
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 'Gefunden über: ' . implode(', ', array_slice($matchedQueries, 0, 3));
|
||
}
|
||
|
||
foreach ($product->highlights as $highlight) {
|
||
$highlight = $this->normalizeOneLine($this->plainTextFromHtml((string) $highlight));
|
||
|
||
if ($highlight !== '') {
|
||
return 'Passender Shop-Hinweis: ' . mb_substr($highlight, 0, 140, 'UTF-8');
|
||
}
|
||
}
|
||
|
||
$matchSource = $this->normalizeOneLine((string) $product->matchSource);
|
||
|
||
if ($matchSource !== '') {
|
||
return 'Trefferquelle: ' . $matchSource;
|
||
}
|
||
|
||
if ($query !== '') {
|
||
return 'Passend zur Suchquery: ' . $query;
|
||
}
|
||
|
||
return 'Aus den Live-Shopdaten übernommen';
|
||
}
|
||
|
||
private function buildFollowUpActionsMessage(bool $isCommerceIntent, bool $hasShopResults, bool $hasKnowledge): string
|
||
{
|
||
if (!$isCommerceIntent && !$hasShopResults && !$hasKnowledge) {
|
||
return '';
|
||
}
|
||
|
||
$actions = [];
|
||
|
||
if ($isCommerceIntent || $hasShopResults) {
|
||
$actions[] = ['Im Shop suchen', 'Suche die aktuelle Produktauswahl im Shop.'];
|
||
$actions[] = ['Nur Zubehör anzeigen', 'Zeige aus der aktuellen Produktauswahl nur Zubehör.'];
|
||
$actions[] = ['Nur Geräte anzeigen', 'Zeige aus der aktuellen Produktauswahl nur Geräte.'];
|
||
$actions[] = ['Preis anzeigen', 'Zeige mir die Preise der aktuell relevanten Produkte.'];
|
||
}
|
||
|
||
if ($hasKnowledge || $hasShopResults) {
|
||
$actions[] = ['Technische Details anzeigen', 'Zeige technische Details zur aktuellen Antwort.'];
|
||
}
|
||
|
||
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">Folgeaktionen</div>'
|
||
. '<div class="retriex-meta-card__title">Was möchtest du als Nächstes tun?</div>'
|
||
. '<div class="retriex-action-chip-row">';
|
||
|
||
foreach ($actions as [$label, $actionPrompt]) {
|
||
$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 : 'keine Suchquery ermittelt';
|
||
}
|
||
|
||
$queryModeLabel = $usedOptimizedQuery ? 'optimiert' : 'direkt';
|
||
$intentLabel = $commerceIntent !== '' ? $commerceIntent : 'commerce';
|
||
$title = $unavailable ? 'Shopdaten nicht verfügbar' : ($completed ? 'Shop-Suche abgeschlossen' : 'Shop-Suche wird ausgeführt');
|
||
$statusLabel = $completed ? 'Status: abgeschlossen' : 'Status: läuft';
|
||
$resultLabel = $unavailable
|
||
? 'Shoptreffer: nicht verfügbar'
|
||
: ($resultCount === null
|
||
? 'Shoptreffer: wird geladen'
|
||
: 'Shoptreffer: ' . max(0, $resultCount));
|
||
$state = $completed ? 'completed' : 'running';
|
||
$resultCountAttribute = $resultCount === null ? '' : (string) max(0, $resultCount);
|
||
$repairLabel = '';
|
||
|
||
if ($usedRepair) {
|
||
$repairLabel = 'Erweiterte Suche: genutzt';
|
||
} elseif ($attemptedRepair) {
|
||
$repairLabel = 'Erweiterte Suche: geprüft';
|
||
}
|
||
|
||
$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">Shop-Suche</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">Query: ' . htmlspecialchars($queryModeLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
||
. '<span class="retriex-meta-pill">Intent: ' . 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>Gesendete Suchquery</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 = 'Keine Detailmeldung vom Shopware-Server.';
|
||
}
|
||
|
||
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">Shopdaten konnten nicht geladen werden</div>'
|
||
. '<div class="retriex-alert__text">RetrieX antwortet ohne Live-Shopdaten weiter. Ursache: '
|
||
. htmlspecialchars($reason, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||
. '</div>'
|
||
. '</div>'
|
||
. '</div>';
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
}
|