Files
MtoRagSystem/AgentRunner.php
2026-05-03 14:27:45 +02:00

3243 lines
116 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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,
};
}
}