This commit is contained in:
team 1
2026-05-04 18:46:26 +02:00
parent 90ced0352a
commit ebd71ba748
15 changed files with 739 additions and 182 deletions

View File

@@ -83,7 +83,7 @@ final readonly class AgentRunner
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Antwort wird vorbereitet',
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('preparing_answer'),
ragCount: null,
shopCount: null,
shopCountMode: $this->resolveShopCountModeForMeta(
@@ -93,7 +93,7 @@ final readonly class AgentRunner
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
),
sourceLabels: $sources,
confidenceLabel: 'Beleglage wird geprüft'
confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('checking_evidence')
),
'meta'
);
@@ -141,7 +141,7 @@ final readonly class AgentRunner
if ($this->isCommerceIntent($commerceIntent)) {
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Shop-Routing erkannt',
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_routing_detected'),
ragCount: null,
shopCount: null,
shopCountMode: $this->resolveShopCountModeForMeta(
@@ -151,7 +151,7 @@ final readonly class AgentRunner
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
),
sourceLabels: $sources,
confidenceLabel: 'Shopdaten werden geprüft'
confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('checking_shop_data')
),
'meta'
);
@@ -174,7 +174,7 @@ final readonly class AgentRunner
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'RAG-Wissen wurde durchsucht',
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('rag_searched'),
ragCount: count($knowledgeChunks),
shopCount: null,
shopCountMode: $this->resolveShopCountModeForMeta(
@@ -202,7 +202,7 @@ final readonly class AgentRunner
if ($this->isCommerceIntent($commerceIntent)) {
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Shop-Suche wird vorbereitet',
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_search_preparing'),
ragCount: count($knowledgeChunks),
shopCount: null,
shopCountMode: 'loading',
@@ -308,7 +308,7 @@ final readonly class AgentRunner
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Mehr Kontext nötig',
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('more_context_needed'),
ragCount: count($knowledgeChunks),
shopCount: null,
shopCountMode: $this->resolveShopCountModeForMeta(
@@ -318,7 +318,7 @@ final readonly class AgentRunner
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
),
sourceLabels: $sources,
confidenceLabel: 'mehr Kontext nötig',
confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('more_context_needed'),
completed: true
),
'meta'
@@ -370,7 +370,7 @@ final readonly class AgentRunner
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Shop wird durchsucht',
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_search_running'),
ragCount: count($knowledgeChunks),
shopCount: null,
shopCountMode: 'loading',
@@ -420,7 +420,7 @@ final readonly class AgentRunner
'meta'
);
$historyNotices[] = $this->buildHistoryNotice(
'Shopdaten konnten nicht geladen werden',
$this->agentRunnerConfig->getProductionUiText('history_notice_shop_unavailable_title'),
$primaryShopSearchFailureReason
);
@@ -431,7 +431,7 @@ final readonly class AgentRunner
'repairQueries' => [],
];
} else {
yield $this->systemMsg('Erweiterte Shopsuche wird geprüft…', 'think');
yield $this->systemMsg($this->agentRunnerConfig->getShopRepairCheckMessage(), 'think');
$repairPayload = $this->repairShopResults(
prompt: $prompt,
@@ -476,7 +476,7 @@ final readonly class AgentRunner
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: $primaryShopSearchHadSystemFailure ? 'Shopdaten nicht verfügbar' : 'Shop-Suche abgeschlossen',
stageLabel: $primaryShopSearchHadSystemFailure ? $this->agentRunnerConfig->getProductionUiStageLabel('shop_unavailable') : $this->agentRunnerConfig->getProductionUiStageLabel('shop_completed'),
ragCount: count($knowledgeChunks),
shopCount: $primaryShopSearchHadSystemFailure ? null : count($shopResults),
shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : 'count',
@@ -542,7 +542,7 @@ final readonly class AgentRunner
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Antwort wird generiert',
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('answer_generating'),
ragCount: count($knowledgeChunks),
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
shopCountMode: $this->resolveShopCountModeForMeta(
@@ -587,7 +587,7 @@ final readonly class AgentRunner
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Abgeschlossen',
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('completed'),
ragCount: count($knowledgeChunks),
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
shopCountMode: $this->resolveShopCountModeForMeta(
@@ -667,7 +667,7 @@ final readonly class AgentRunner
$userErrorMessage = $this->buildUserErrorMessage($e);
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Antwort wurde unterbrochen',
stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('interrupted'),
ragCount: count($knowledgeChunks),
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
shopCountMode: $this->resolveShopCountModeForMeta(
@@ -677,7 +677,7 @@ final readonly class AgentRunner
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
),
sourceLabels: $sources,
confidenceLabel: 'nicht abgeschlossen',
confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('interrupted'),
completed: true
),
'meta'
@@ -686,7 +686,7 @@ final readonly class AgentRunner
$historyResponse = $this->buildHistoryResponse('', array_merge(
$historyNotices,
[$this->buildHistoryNotice('Antwort konnte nicht abgeschlossen werden', $e->getMessage())]
[$this->buildHistoryNotice($this->agentRunnerConfig->getProductionUiText('history_notice_answer_incomplete_title'), $e->getMessage())]
));
if ($historyResponse !== '') {
@@ -985,12 +985,7 @@ final readonly class AgentRunner
private function normalizeFuzzyRoutingToken(string $token): string
{
$token = mb_strtolower(trim($token), 'UTF-8');
$token = strtr($token, [
'ä' => 'ae',
'ö' => 'oe',
'ü' => 'ue',
'ß' => 'ss',
]);
$token = $this->languageCleanupConfig->transliterateToAscii($token);
$token = preg_replace('/[^a-z0-9]+/u', '', $token) ?? $token;
return trim($token);
@@ -1028,13 +1023,10 @@ final readonly class AgentRunner
{
$normalized = $this->normalizeRoutingComparisonText($candidate);
return in_array($normalized, [
'normalized user input',
'corrected user input',
'user input',
'normalisierte nutzereingabe',
'korrigierte nutzereingabe',
], true);
return in_array($normalized, array_map(
fn (string $placeholder): string => $this->normalizeRoutingComparisonText($placeholder),
$this->agentRunnerConfig->getInputNormalizationPlaceholderOutputs()
), true);
}
private function normalizeRoutingComparisonText(string $value): string
@@ -1132,7 +1124,7 @@ final readonly class AgentRunner
$lines = [];
foreach ($previousQuestions as $question) {
$lines[] = 'Vorherige Nutzerfrage: ' . $question;
$lines[] = $this->renderAgentTemplate($this->agentRunnerConfig->getFollowUpContextPreviousUserQuestionTemplate(), ['question' => $question]);
}
if ($referenceAnchors !== []) {
@@ -1140,7 +1132,7 @@ final readonly class AgentRunner
. implode(' ', $referenceAnchors);
}
$lines[] = 'Aktuelle Folgefrage: ' . $prompt;
$lines[] = $this->renderAgentTemplate($this->agentRunnerConfig->getFollowUpContextCurrentQuestionTemplate(), ['question' => $prompt]);
return implode("\n", $lines);
}
@@ -1354,7 +1346,7 @@ final readonly class AgentRunner
private function normalizeFollowUpText(string $value): string
{
$value = mb_strtolower(trim($value), 'UTF-8');
$value = str_replace(['-', '/', '_'], ' ', $value);
$value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value);
$value = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
@@ -1389,7 +1381,7 @@ final readonly class AgentRunner
}
if (time() - $lastHeartbeatAt >= 2) {
yield $this->systemMsg('Shop-Suchanfrage wird optimiert…', 'think');
yield $this->systemMsg($this->agentRunnerConfig->getShopQueryOptimizationHeartbeatMessage(), 'think');
$lastHeartbeatAt = time();
}
@@ -2027,7 +2019,7 @@ final readonly class AgentRunner
private function tokenizeShopQueryCandidate(string $value): array
{
$value = mb_strtolower(trim($value), 'UTF-8');
$value = str_replace(['-', '/', '_'], ' ', $value);
$value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value);
if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', $value, $matches) !== 1) {
return [];
@@ -2077,7 +2069,7 @@ final readonly class AgentRunner
private function tokenizeMetaGuardText(string $value): array
{
$value = mb_strtolower(trim($value), 'UTF-8');
$value = str_replace(['-', '/', '_'], ' ', $value);
$value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value);
$value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
$value = trim($value);
@@ -2232,7 +2224,10 @@ final readonly class AgentRunner
}
$template = $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTemplate();
$enriched = str_replace(['{anchor}', '{query}'], [$anchor, $query], $template);
$enriched = $this->renderAgentTemplate($template, [
'anchor' => $anchor,
'query' => $query,
]);
$enriched = preg_replace('/\s+/u', ' ', $enriched) ?? $enriched;
return trim($enriched) !== '' ? trim($enriched) : $query;
@@ -2505,7 +2500,10 @@ final readonly class AgentRunner
: $this->agentRunnerConfig->getNoLlmFallbackShopUnavailableNoKnowledgeMessage();
if ($reason !== '') {
$message .= ' Ursache: ' . $reason;
$message = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('unavailable_reason_template'), [
'message' => $message,
'reason' => $reason,
]);
}
return trim($message);
@@ -2542,7 +2540,7 @@ final readonly class AgentRunner
}
if ($lines === []) {
return ['- Es wurden Shop-Treffer übergeben, aber keine lesbaren Produktdaten gefunden.'];
return [$this->agentRunnerConfig->getNoLlmProductField('unreadable_results_message')];
}
return $lines;
@@ -2554,33 +2552,36 @@ final readonly class AgentRunner
$productRole = $this->resolveNoLlmShopProductRole($product);
$name = $this->normalizeOneLine($product->name);
$parts[] = $name !== '' ? $name : 'Unbenanntes Shop-Produkt';
$parts[] = $name !== '' ? $name : $this->agentRunnerConfig->getNoLlmProductField('unnamed_product');
if ($product->productNumber !== null && trim($product->productNumber) !== '') {
$parts[] = 'Art.-Nr. ' . $this->normalizeOneLine($product->productNumber);
$parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('product_number_template'), ['value' => $this->normalizeOneLine($product->productNumber)]);
}
if ($product->manufacturer !== null && trim($product->manufacturer) !== '') {
$parts[] = 'Hersteller: ' . $this->normalizeOneLine($product->manufacturer);
$parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('manufacturer_template'), ['value' => $this->normalizeOneLine($product->manufacturer)]);
}
if ($product->price !== null && trim($product->price) !== '') {
$parts[] = 'Preis: ' . $this->normalizeOneLine($product->price);
$parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('price_template'), ['value' => $this->normalizeOneLine($product->price)]);
}
if ($product->available !== null) {
$parts[] = 'Verfügbar: ' . ($product->available ? 'ja' : 'nein');
$parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('availability_template'), ['value' => $product->available ? $this->agentRunnerConfig->getNoLlmProductField('availability_yes') : $this->agentRunnerConfig->getNoLlmProductField('availability_no')]);
}
if ($product->url !== null && trim($product->url) !== '') {
$parts[] = 'URL: ' . $this->normalizeOneLine($product->url);
$parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('url_template'), ['value' => $this->normalizeOneLine($product->url)]);
}
if ($requestedProductRole === 'main_device_or_system' && $productRole === 'accessory_or_consumable') {
$parts[] = 'Hinweis: Zubehör/Verbrauchsartikel; nicht als Messanlage/Gerät bestätigt';
$parts[] = $this->agentRunnerConfig->getNoLlmProductField('incompatible_role_note');
}
return sprintf('%d. %s', $index, implode(' | ', $parts));
return $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('line_template'), [
'index' => (string) $index,
'parts' => implode($this->agentRunnerConfig->getNoLlmProductField('separator'), $parts),
]);
}
/**
@@ -2722,20 +2723,20 @@ final readonly class AgentRunner
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',
'direct' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct'),
'aggregate_missing' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing'),
'weak' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('weak'),
default => $this->agentRunnerConfig->getProductionUiConfidenceLabel('default'),
};
}
private function resolveRagEvidenceShopCheckConfidenceLabel(string $knowledgeEvidenceState): string
{
return match ($knowledgeEvidenceState) {
'direct' => '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',
'direct' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct_shop_check'),
'aggregate_missing' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_shop_check'),
'weak' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('weak_shop_check'),
default => $this->agentRunnerConfig->getProductionUiConfidenceLabel('default_shop_check'),
};
}
@@ -2902,13 +2903,8 @@ final readonly class AgentRunner
{
$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 = $this->languageCleanupConfig->normalizeDashEquivalents($value);
$value = $this->languageCleanupConfig->transliterateToAscii($value);
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
return trim($value);
@@ -2957,7 +2953,7 @@ final readonly class AgentRunner
$noLlmMessage = $this->plainTextFromHtml($this->agentRunnerConfig->getNoLlmDataReceivedMessage());
if ($noLlmMessage === '') {
$noLlmMessage = 'Es wurden keine Daten vom LLM empfangen.';
$noLlmMessage = $this->agentRunnerConfig->getProductionUiText('no_llm_history_default');
}
$parts[] = 'Systemhinweis: ' . $noLlmMessage;
@@ -2972,18 +2968,21 @@ final readonly class AgentRunner
$detail = $this->normalizeOneLine($this->plainTextFromHtml((string) $detail));
if ($title === '') {
$title = 'Systemhinweis';
$title = $this->agentRunnerConfig->getProductionUiText('history_notice_default_title');
}
if ($detail === '') {
return 'Systemhinweis: ' . $title . '.';
return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('history_notice_without_detail'), ['title' => $title]);
}
if (mb_strlen($detail, 'UTF-8') > 500) {
$detail = rtrim(mb_substr($detail, 0, 497, 'UTF-8')) . '...';
}
return 'Systemhinweis: ' . $title . '. Ursache: ' . $detail;
return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('history_notice_with_detail'), [
'title' => $title,
'detail' => $detail,
]);
}
private function plainTextFromHtml(string $value): string
@@ -3033,32 +3032,34 @@ final readonly class AgentRunner
): string {
$state = $completed ? 'completed' : 'running';
$ragLabel = $ragCount === null
? 'RAG-Treffer: wird geprüft'
: 'RAG-Treffer: ' . max(0, $ragCount);
? $this->agentRunnerConfig->getProductionUiText('rag_hits_checking')
: $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('rag_hits_count'), ['count' => (string) 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',
'count' => $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_hits_count'), ['count' => (string) max(0, (int) $shopCount)]),
'loading' => $this->agentRunnerConfig->getProductionUiText('shop_hits_loading'),
'unavailable' => $this->agentRunnerConfig->getProductionUiText('shop_hits_unavailable'),
'not_resolved' => $this->agentRunnerConfig->getProductionUiText('shop_hits_no_query'),
default => $this->agentRunnerConfig->getProductionUiText('shop_hits_not_requested'),
};
$statusLabel = $completed ? 'Status: abgeschlossen' : 'Status: läuft';
$statusLabel = $completed
? $this->agentRunnerConfig->getProductionUiText('status_completed')
: $this->agentRunnerConfig->getProductionUiText('status_running');
$sources = $this->formatProductionUiSourceLabels($sourceLabels);
$html = '<div class="retriex-meta-card retriex-run-meta" data-retriex-meta-id="run-status" data-retriex-meta-state="'
. htmlspecialchars($state, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '">'
. '<div class="retriex-meta-card__eyebrow">RetrieX-Status</div>'
. '<div class="retriex-meta-card__eyebrow">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('run_status_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-meta-card__title">' . htmlspecialchars($stageLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-meta-card__body">'
. '<span class="retriex-meta-pill retriex-meta-pill--status">' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($ragLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($shopLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '<span class="retriex-meta-pill retriex-meta-pill--confidence">Beleglage: ' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '<span class="retriex-meta-pill retriex-meta-pill--confidence">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('evidence_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '</div>';
if ($sources !== []) {
$html .= '<div class="retriex-source-overview"><span>Datenbasis</span><div class="retriex-source-overview__badges">';
$html .= '<div class="retriex-source-overview"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('data_basis_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><div class="retriex-source-overview__badges">';
foreach ($sources as $source) {
$html .= '<span class="retriex-source-chip">' . htmlspecialchars($source, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';
@@ -3066,8 +3067,10 @@ final readonly class AgentRunner
$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">'
$emptySourceLabel = $completed
? $this->agentRunnerConfig->getProductionUiText('data_basis_empty_completed')
: $this->agentRunnerConfig->getProductionUiText('data_basis_empty_running');
$html .= '<div class="retriex-source-overview"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('data_basis_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><div class="retriex-source-overview__empty">'
. htmlspecialchars($emptySourceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</div></div>';
}
@@ -3087,35 +3090,37 @@ final readonly class AgentRunner
): 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';
? $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_shop_unavailable')
: $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_no_count');
}
if ($shopSearchHadSystemFailure) {
return $hasKnowledge ? 'fachlich belegt; Shopdaten nicht verfügbar' : 'Shopdaten nicht verfügbar';
return $hasKnowledge
? $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_unavailable_with_knowledge')
: $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_unavailable');
}
if ($hasKnowledge && $hasShopResults) {
return 'RAG + Shopdaten';
return $this->agentRunnerConfig->getProductionUiConfidenceLabel('rag_and_shop');
}
if (!$hasKnowledge && $hasShopResults) {
return 'nur Shopdaten';
return $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_only');
}
if ($hasKnowledge && $shopSearchAttempted) {
return 'RAG-Wissen, keine Shop-Treffer';
return $this->agentRunnerConfig->getProductionUiConfidenceLabel('rag_no_shop_hits');
}
if ($hasKnowledge) {
return 'fachlich belegt';
return $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct');
}
if ($isCommerceIntent || $shopSearchAttempted) {
return 'keine belastbaren Daten';
return $this->agentRunnerConfig->getProductionUiConfidenceLabel('no_reliable_data');
}
return 'noch keine belastbaren Treffer';
return $this->agentRunnerConfig->getProductionUiConfidenceLabel('no_reliable_hits');
}
/**
@@ -3137,7 +3142,7 @@ final readonly class AgentRunner
}
if ($label === $this->plainTextFromHtml($this->agentRunnerConfig->getShopSystemSourceLabel())) {
$label = 'Live-Shopdaten';
$label = $this->agentRunnerConfig->getProductionUiText('live_shop_source_plain_label');
}
if (!in_array($label, $labels, true)) {
@@ -3153,27 +3158,27 @@ final readonly class AgentRunner
*/
private function buildShopProductCardsMessage(array $shopResults, string $query, bool $usedRepair): string
{
$maxCards = 5;
$maxCards = max(1, $this->agentRunnerConfig->getProductionUiShopResultsMaxCards());
$visibleResults = array_slice($shopResults, 0, $maxCards);
$totalCount = count($shopResults);
$query = $this->normalizeOneLine($query);
$summary = $totalCount . ' Shop-Treffer ausgewertet';
$summary = $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_results_summary'), ['count' => (string) $totalCount]);
if ($totalCount > $maxCards) {
$summary .= ' · Top ' . $maxCards . ' angezeigt';
$summary .= $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_results_top_displayed_suffix'), ['max' => (string) $maxCards]);
}
if ($usedRepair) {
$summary .= ' · erweiterte Shopsuche genutzt';
$summary .= $this->agentRunnerConfig->getProductionUiTemplate('shop_results_repair_suffix');
}
$html = '<div class="retriex-meta-card retriex-product-results" data-retriex-meta-id="shop-results" data-retriex-meta-state="completed">'
. '<div class="retriex-meta-card__eyebrow">Shop-Ergebnisse</div>'
. '<div class="retriex-meta-card__title">Shop-Ergebnisse</div>'
. '<div class="retriex-meta-card__eyebrow">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_results_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-meta-card__title">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_results_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-product-results__summary">' . htmlspecialchars($summary, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>';
if ($query !== '') {
$html .= '<div class="retriex-meta-query retriex-meta-query--compact"><span>Ausgewertete Suchquery</span><code>'
$html .= '<div class="retriex-meta-query retriex-meta-query--compact"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('evaluated_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><code>'
. htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</code></div>';
}
@@ -3195,7 +3200,7 @@ final readonly class AgentRunner
private function buildShopProductCard(ShopProductResult $product, string $query): string
{
$name = $this->normalizeOneLine($product->name) ?: 'Unbenanntes Produkt';
$name = $this->normalizeOneLine($product->name) ?: $this->agentRunnerConfig->getProductionUiText('unnamed_product');
$productNumber = $this->normalizeOneLine((string) $product->productNumber);
$manufacturer = $this->normalizeOneLine((string) $product->manufacturer);
$price = $this->normalizeOneLine((string) $product->price);
@@ -3215,16 +3220,16 @@ final readonly class AgentRunner
}
$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>';
$html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('product_number_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($productNumber !== '' ? $productNumber : $this->agentRunnerConfig->getProductionUiText('field_not_provided'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
$html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('price_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($price !== '' ? $price : $this->agentRunnerConfig->getProductionUiText('field_not_provided'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
$html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('availability_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
if ($manufacturer !== '') {
$html .= '<div><dt>Hersteller</dt><dd>' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
$html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('manufacturer_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
}
$html .= '</dl>'
. '<div class="retriex-product-card__relevance"><span>Relevanz</span>'
. '<div class="retriex-product-card__relevance"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('relevance_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. htmlspecialchars($relevance, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</div>'
. '</article>';
@@ -3235,9 +3240,9 @@ final readonly class AgentRunner
private function formatProductAvailability(?bool $available): string
{
return match ($available) {
true => 'verfügbar',
false => 'nicht verfügbar',
default => 'Shopstatus nicht übermittelt',
true => $this->agentRunnerConfig->getProductionUiText('availability_yes'),
false => $this->agentRunnerConfig->getProductionUiText('availability_no'),
default => $this->agentRunnerConfig->getProductionUiText('availability_unknown'),
};
}
@@ -3254,28 +3259,32 @@ final readonly class AgentRunner
}
if ($matchedQueries !== []) {
return 'Gefunden über: ' . implode(', ', array_slice($matchedQueries, 0, 3));
return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_matched_queries'), [
'queries' => implode(', ', array_slice($matchedQueries, 0, 3)),
]);
}
foreach ($product->highlights as $highlight) {
$highlight = $this->normalizeOneLine($this->plainTextFromHtml((string) $highlight));
if ($highlight !== '') {
return 'Passender Shop-Hinweis: ' . mb_substr($highlight, 0, 140, 'UTF-8');
return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_highlight'), [
'highlight' => mb_substr($highlight, 0, 140, 'UTF-8'),
]);
}
}
$matchSource = $this->normalizeOneLine((string) $product->matchSource);
if ($matchSource !== '') {
return 'Trefferquelle: ' . $matchSource;
return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_match_source'), ['source' => $matchSource]);
}
if ($query !== '') {
return 'Passend zur Suchquery: ' . $query;
return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_query'), ['query' => $query]);
}
return 'Aus den Live-Shopdaten übernommen';
return $this->agentRunnerConfig->getProductionUiTemplate('relevance_default');
}
private function buildFollowUpActionsMessage(bool $isCommerceIntent, bool $hasShopResults, bool $hasKnowledge): string
@@ -3287,14 +3296,11 @@ final readonly class AgentRunner
$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.'];
$actions = array_merge($actions, $this->agentRunnerConfig->getProductionUiFollowUpActions('commerce'));
}
if ($hasKnowledge || $hasShopResults) {
$actions[] = ['Technische Details anzeigen', 'Zeige technische Details zur aktuellen Antwort.'];
$actions = array_merge($actions, $this->agentRunnerConfig->getProductionUiFollowUpActions('knowledge'));
}
if ($actions === []) {
@@ -3302,11 +3308,16 @@ final readonly class AgentRunner
}
$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-meta-card__eyebrow">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('followup_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-meta-card__title">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('followup_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-action-chip-row">';
foreach ($actions as [$label, $actionPrompt]) {
foreach ($actions as $action) {
$label = (string) ($action['label'] ?? '');
$actionPrompt = (string) ($action['prompt'] ?? '');
if ($label === '' || $actionPrompt === '') {
continue;
}
$html .= '<button type="button" class="retriex-action-chip" data-retriex-action-prompt="'
. htmlspecialchars($actionPrompt, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '">'
@@ -3334,26 +3345,32 @@ final readonly class AgentRunner
$originalQuery = $this->normalizeOneLine($originalQuery);
if ($query === '') {
$query = $originalQuery !== '' ? $originalQuery : 'keine Suchquery ermittelt';
$query = $originalQuery !== '' ? $originalQuery : $this->agentRunnerConfig->getProductionUiText('shop_meta_fallback_query');
}
$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';
$queryModeLabel = $usedOptimizedQuery ? $this->agentRunnerConfig->getProductionUiText('shop_meta_query_mode_optimized') : $this->agentRunnerConfig->getProductionUiText('shop_meta_query_mode_direct');
$intentLabel = $commerceIntent !== '' ? $commerceIntent : $this->agentRunnerConfig->getProductionUiText('shop_meta_default_intent');
$title = $unavailable
? $this->agentRunnerConfig->getProductionUiText('shop_meta_title_unavailable')
: ($completed
? $this->agentRunnerConfig->getProductionUiText('shop_meta_title_completed')
: $this->agentRunnerConfig->getProductionUiText('shop_meta_title_running'));
$statusLabel = $completed
? $this->agentRunnerConfig->getProductionUiText('shop_meta_status_completed')
: $this->agentRunnerConfig->getProductionUiText('shop_meta_status_running');
$resultLabel = $unavailable
? 'Shoptreffer: nicht verfügbar'
? $this->agentRunnerConfig->getProductionUiText('shop_meta_result_unavailable')
: ($resultCount === null
? 'Shoptreffer: wird geladen'
: 'Shoptreffer: ' . max(0, $resultCount));
? $this->agentRunnerConfig->getProductionUiText('shop_meta_result_loading')
: $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_meta_result_count'), ['count' => (string) max(0, $resultCount)]));
$state = $completed ? 'completed' : 'running';
$resultCountAttribute = $resultCount === null ? '' : (string) max(0, $resultCount);
$repairLabel = '';
if ($usedRepair) {
$repairLabel = 'Erweiterte Suche: genutzt';
$repairLabel = $this->agentRunnerConfig->getProductionUiText('shop_meta_repair_used');
} elseif ($attemptedRepair) {
$repairLabel = 'Erweiterte Suche: geprüft';
$repairLabel = $this->agentRunnerConfig->getProductionUiText('shop_meta_repair_checked');
}
$html = '<div class="retriex-meta-card retriex-shop-meta" data-retriex-meta-id="shop-search" data-retriex-meta-state="'
@@ -3361,20 +3378,20 @@ final readonly class AgentRunner
. '" 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__eyebrow">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-meta-card__title">' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-meta-card__body">'
. '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($resultLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '<span class="retriex-meta-pill">' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '<span class="retriex-meta-pill">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>';
. '<span class="retriex-meta-pill">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($queryModeLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '<span class="retriex-meta-pill">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_intent_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($intentLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';
if ($repairLabel !== '') {
$html .= '<span class="retriex-meta-pill">' . htmlspecialchars($repairLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';
}
$html .= '</div>'
. '<div class="retriex-meta-query"><span>Gesendete Suchquery</span><code>'
. '<div class="retriex-meta-query"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><code>'
. htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</code></div>'
. '</div>';
@@ -3387,7 +3404,7 @@ final readonly class AgentRunner
$reason = $this->normalizeOneLine((string) $reason);
if ($reason === '') {
$reason = 'Keine Detailmeldung vom Shopware-Server.';
$reason = $this->agentRunnerConfig->getProductionUiText('shop_unavailable_default_reason');
}
if (mb_strlen($reason, 'UTF-8') > 320) {
@@ -3397,14 +3414,27 @@ final readonly class AgentRunner
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: '
. '<div class="retriex-alert__title">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_unavailable_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-alert__text">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_unavailable_text_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''
. htmlspecialchars($reason, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</div>'
. '</div>'
. '</div>';
}
/**
* @param array<string, string> $values
*/
private function renderAgentTemplate(string $template, array $values): string
{
foreach ($values as $key => $value) {
$template = str_replace('{' . $key . '}', $value, $template);
}
return $template;
}
private function normalizeOneLine(string $value): string
{
$value = trim($value);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Agent;
use App\Commerce\Dto\ShopProductResult;
use App\Config\LanguageCleanupConfig;
use App\Config\PromptBuilderConfig;
use App\Context\ContextService;
use App\Repository\SystemPromptRepository;
@@ -19,6 +20,7 @@ final readonly class PromptBuilder
private SystemPromptRepository $systemPromptRepository,
private ModelGenerationConfigProvider $modelGenerationConfigProvider,
private PromptBuilderConfig $config,
private LanguageCleanupConfig $languageCleanupConfig,
) {
}
@@ -335,7 +337,7 @@ final readonly class PromptBuilder
}
if ($hasShopResults && !$commerceSearchAttempted) {
$rules[] = '- Treat shop results as provided context only; do not imply that a live shop check was performed in this run.';
$rules[] = $this->config->getFallbackEscalationProvidedShopResultsContextRule();
}
if ($rules === []) {
@@ -609,7 +611,7 @@ final readonly class PromptBuilder
$positiveContextTerms = $this->extractMeasurementGuardStringList($guard, 'positive_context_terms');
$negativeContextTerms = $this->extractMeasurementGuardStringList($guard, 'negative_context_terms');
$nonEquivalentTerms = $this->extractMeasurementGuardStringList($guard, 'non_equivalent_terms');
$label = $this->normalizeBlockText((string) ($guard['label'] ?? 'requested measurement parameter'));
$label = $this->normalizeBlockText((string) ($guard['label'] ?? $this->config->getMeasurementEvidenceRuleTemplate('default_requested_parameter_label')));
$strictNoEvidence = (bool) ($guard['strict_no_evidence'] ?? true);
$resolvedRequestedRole = $requestedRole ?? $this->resolveRequestedProductRole($prompt);
$safeNoEvidenceAnswer = $this->normalizeBlockText((string) (
@@ -634,58 +636,59 @@ final readonly class PromptBuilder
if ($hasEvidence) {
$shopHasEvidence = true;
$shopEvidenceLines[] = sprintf(
'- Shop record %d (%s): explicit positive evidence for %s is present in this same record.',
$index + 1,
$productName !== '' ? $productName : 'unnamed product',
$label
);
$shopEvidenceLines[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('shop_positive_evidence'), [
'index' => (string) ($index + 1),
'product' => $productName !== '' ? $productName : $this->config->getMeasurementEvidenceRuleTemplate('unnamed_product'),
'label' => $label,
]);
}
}
if ($shopEvidenceLines === []) {
$shopEvidenceLines[] = sprintf(
'- No shop product record shown to the model contains explicit positive evidence for %s in the same record.',
$label
);
$shopEvidenceLines[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('shop_no_evidence'), [
'label' => $label,
]);
}
$rules = $this->config->getMeasurementEvidenceIntroRules();
$rules = array_merge($rules, $this->config->getMeasurementEvidenceProductSpecificRules());
$rules[] = '- User requested measurement parameter: ' . $label . '.';
$rules[] = '- Positive parameter terms for this request: ' . implode(', ', $positiveTerms) . '.';
$rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('requested_parameter'), ['label' => $label]);
$rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('positive_terms'), ['terms' => implode(', ', $positiveTerms)]);
if ($positiveContextTerms !== []) {
$rules[] = '- These parameter terms count as suitability evidence only in a measurement-purpose context such as: ' . implode(', ', $positiveContextTerms) . '.';
$rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('positive_context_terms'), ['terms' => implode(', ', $positiveContextTerms)]);
}
if ($negativeContextTerms !== []) {
$rules[] = '- These contexts are not suitability evidence by themselves: ' . implode(', ', $negativeContextTerms) . '.';
$rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('negative_context_terms'), ['terms' => implode(', ', $negativeContextTerms)]);
}
if ($nonEquivalentTerms !== []) {
$rules[] = '- Terms that must NOT be treated as equivalent positive evidence: ' . implode(', ', $nonEquivalentTerms) . '.';
$rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('non_equivalent_terms'), ['terms' => implode(', ', $nonEquivalentTerms)]);
}
$rules[] = '- RAG/URL evidence scan for this exact parameter: ' . ($knowledgeHasEvidence ? 'explicit positive evidence found.' : 'no explicit positive evidence found.');
$rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('rag_url_evidence_scan'), [
'state' => $knowledgeHasEvidence
? $this->config->getMeasurementEvidenceRuleTemplate('rag_url_evidence_found')
: $this->config->getMeasurementEvidenceRuleTemplate('rag_url_evidence_missing'),
]);
$rules = array_merge($rules, $shopEvidenceLines);
if (!$strictNoEvidence && !$knowledgeHasEvidence && !$shopHasEvidence) {
$rules[] = '- The deterministic exact-term scan did not find product-specific evidence. The answer may still use a clearly equivalent named measurement parameter from the same source record, but must not infer suitability from generic categories, document titles, tags, search terms, neighbouring products, or broad umbrella-topic wording.';
$rules[] = $this->config->getMeasurementEvidenceRuleTemplate('deterministic_scan_no_product_specific_evidence');
}
if ($strictNoEvidence && !$knowledgeHasEvidence && !$shopHasEvidence) {
$rules[] = '- Mandatory answer behavior: do not recommend a product as suitable for this measurement parameter.';
$rules[] = $this->config->getMeasurementEvidenceRuleTemplate('mandatory_no_recommendation');
if ($safeNoEvidenceAnswer !== '') {
$rules[] = '- Start the answer with this meaning in the user language: ' . $safeNoEvidenceAnswer;
$rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('start_answer_meaning'), ['answer' => $safeNoEvidenceAnswer]);
}
if ($resolvedRequestedRole === 'accessory_or_consumable') {
$rules[] = '- Do not recommend accessories for a different measurement parameter just because they are accessories. If only accessories for other parameters are present, say that only non-matching accessory hits were found.';
$rules[] = $this->config->getMeasurementEvidenceRuleTemplate('accessory_mismatch');
} else {
$rules[] = '- You may list exact shop hits only as commercial/search hits under a heading such as "Shop-Treffer (technische Eignung nicht sicher belegt)".';
$rules[] = $this->config->getMeasurementEvidenceRuleTemplate('commercial_hits_only');
}
}
$rules[] = '- Do not output measurement ranges, methods, application areas, advantages, or alternative suitable models unless the same source record contains explicit positive evidence for the requested measurement parameter.';
$rules[] = '- The generated shop search query, search intent, ranking position, and user question are not factual evidence for product suitability.';
$rules = array_merge($rules, $this->config->getMeasurementEvidenceFinalRules());
return $this->buildRuleBlock(
$this->config->getMeasurementEvidenceSectionLabel(),
@@ -693,6 +696,19 @@ final readonly class PromptBuilder
);
}
/**
* @param array<string, string> $values
*/
private function renderPromptTemplate(string $template, array $values): string
{
foreach ($values as $key => $value) {
$template = str_replace('{' . $key . '}', $value, $template);
}
return $template;
}
private function buildShopMeasurementEvidenceLine(ShopProductResult $product, ?array $guard): string
{
if ($guard === null) {
@@ -702,23 +718,21 @@ final readonly class PromptBuilder
$positiveTerms = $this->extractMeasurementGuardStringList($guard, 'positive_terms');
$positiveContextTerms = $this->extractMeasurementGuardStringList($guard, 'positive_context_terms');
$negativeContextTerms = $this->extractMeasurementGuardStringList($guard, 'negative_context_terms');
$label = $this->normalizeBlockText((string) ($guard['label'] ?? 'requested measurement parameter'));
$label = $this->normalizeBlockText((string) ($guard['label'] ?? $this->config->getMeasurementEvidenceRuleTemplate('default_requested_parameter_label')));
if ($positiveTerms === []) {
return '';
}
if ($this->shopProductHasMeasurementEvidence($product, $positiveTerms, $positiveContextTerms, $negativeContextTerms)) {
return sprintf(
'Requested measurement evidence: explicit positive evidence for %s is present in this same SHOP PRODUCT RECORD.',
$label
);
return $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('shop_record_positive_evidence_line'), [
'label' => $label,
]);
}
return sprintf(
'Requested measurement evidence: no explicit positive evidence for %s is present in this SHOP PRODUCT RECORD. Do not present this record as technically suitable for that measurement parameter.',
$label
);
return $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('shop_record_no_evidence_line'), [
'label' => $label,
]);
}
private function resolveRequestedMeasurementGuard(string $prompt): ?array
@@ -815,11 +829,11 @@ final readonly class PromptBuilder
return;
}
$parts = preg_split('/\s*(?:,|;|\/|\boder\b|\bund\b|\bor\b|\band\b)\s*/iu', $value) ?: [$value];
$parts = preg_split($this->config->getParameterParsingSplitPattern(), $value) ?: [$value];
foreach ($parts as $part) {
$part = $this->normalizeBlockText((string) $part);
$part = trim($part, " \t\n\r\0\x0B-–—:()[]{}\"'`“”„");
$part = trim($part, $this->config->getParameterParsingTrimCharacters());
if ($part === '' || preg_match('/[\p{L}\p{N}]/u', $part) !== 1) {
continue;
@@ -835,7 +849,7 @@ final readonly class PromptBuilder
private function renderMeasurementEvidenceTemplate(string $template, string $label): string
{
return strtr($template, ['{label}' => $label]);
return $this->renderPromptTemplate($template, ['label' => $label]);
}
/**
@@ -992,7 +1006,7 @@ final readonly class PromptBuilder
private function normalizeForMeasurementMatching(string $value): string
{
$value = mb_strtolower($this->normalizeBlockText($value), 'UTF-8');
$value = str_replace(['', '', '', '', '—'], '-', $value);
$value = $this->languageCleanupConfig->normalizeDashEquivalents($value);
$value = preg_replace('/<[^>]+>/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;

View File

@@ -135,6 +135,22 @@ final class AgentRunnerConfig
return $this->getRequiredString('follow_up_context.reference_anchor.hardness_value_pattern');
}
public function getFollowUpContextPreviousUserQuestionTemplate(): string
{
return $this->getRequiredString('follow_up_context.context_labels.previous_user_question_template');
}
public function getFollowUpContextPreviousReferenceAnchorsTemplate(): string
{
return $this->getRequiredString('follow_up_context.context_labels.previous_reference_anchors_template');
}
public function getFollowUpContextCurrentQuestionTemplate(): string
{
return $this->getRequiredString('follow_up_context.context_labels.current_follow_up_question_template');
}
public function isInputNormalizationEnabled(): bool
{
return $this->getRequiredBool('input_normalization.enabled');
@@ -170,6 +186,14 @@ final class AgentRunnerConfig
return $this->getRequiredString('input_normalization.output_prefix_pattern');
}
/**
* @return string[]
*/
public function getInputNormalizationPlaceholderOutputs(): array
{
return $this->getRequiredStringList('input_normalization.placeholder_outputs');
}
/**
* @return string[]
*/
@@ -396,6 +420,45 @@ final class AgentRunnerConfig
return $out;
}
/**
* @return array<int, array{label:string, prompt:string}>
*/
private function getRequiredActionList(string $key): array
{
$value = $this->requiredValue($key);
if (!is_array($value)) {
throw new \InvalidArgumentException(sprintf('RetrieX agent config key "%s" must be a list of action definitions.', $key));
}
$out = [];
foreach ($value as $item) {
if (!is_array($item)) {
continue;
}
$label = isset($item['label']) && is_scalar($item['label']) ? trim((string) $item['label']) : '';
$prompt = isset($item['prompt']) && is_scalar($item['prompt']) ? trim((string) $item['prompt']) : '';
if ($label === '' || $prompt === '') {
continue;
}
$out[] = [
'label' => $label,
'prompt' => $prompt,
];
}
if ($out === []) {
throw new \InvalidArgumentException(sprintf('RetrieX agent config key "%s" must contain at least one valid action definition.', $key));
}
return $out;
}
/**
* @return array<string, string[]>
*/
@@ -637,6 +700,55 @@ final class AgentRunnerConfig
return $this->getRequiredString('no_llm_fallback.messages.no_data');
}
public function getShopRepairCheckMessage(): string
{
return $this->getRequiredString('messages.shop_repair_check');
}
public function getShopQueryOptimizationHeartbeatMessage(): string
{
return $this->getRequiredString('messages.shop_query_optimization_heartbeat');
}
public function getProductionUiStageLabel(string $key): string
{
return $this->getRequiredString('production_ui.stage_labels.' . $key);
}
public function getProductionUiConfidenceLabel(string $key): string
{
return $this->getRequiredString('production_ui.confidence_labels.' . $key);
}
public function getProductionUiText(string $key): string
{
return $this->getRequiredString('production_ui.text.' . $key);
}
public function getProductionUiTemplate(string $key): string
{
return $this->getRequiredString('production_ui.templates.' . $key);
}
public function getProductionUiShopResultsMaxCards(): int
{
return $this->getRequiredInt('production_ui.shop_results.max_cards');
}
/**
* @return array<int, array{label:string, prompt:string}>
*/
public function getProductionUiFollowUpActions(string $group): array
{
return $this->getRequiredActionList('production_ui.follow_up_actions.' . $group);
}
public function getNoLlmProductField(string $key): string
{
return $this->getRequiredString('no_llm_fallback.product_fields.' . $key);
}
public function getNoLlmFallbackNoShopResultsWithKnowledgeMessage(): string
{
return $this->getRequiredString('no_llm_fallback.messages.no_shop_results_with_knowledge');

View File

@@ -65,6 +65,29 @@ final class LanguageCleanupConfig
return strtr($value, $map);
}
/** @return string[] */
public function getWordSeparatorCharacters(): array
{
return $this->getNormalizationStringList('word_separator_chars');
}
/** @return string[] */
public function getDashEquivalents(): array
{
return $this->getNormalizationStringList('dash_equivalents');
}
public function replaceWordSeparatorsWithSpace(string $value): string
{
return str_replace($this->getWordSeparatorCharacters(), ' ', $value);
}
public function normalizeDashEquivalents(string $value): string
{
return str_replace($this->getDashEquivalents(), '-', $value);
}
/** @return string[] */
public function getCleanupProfileNames(): array
{

View File

@@ -306,6 +306,11 @@ final class PromptBuilderConfig
return $this->getRequiredString('fallback_escalation.state_line_template');
}
public function getFallbackEscalationProvidedShopResultsContextRule(): string
{
return $this->getRequiredString('fallback_escalation.provided_shop_results_context_rule');
}
/**
* @return string[]
*/
@@ -623,6 +628,29 @@ final class PromptBuilderConfig
return $this->getRequiredString('measurement_evidence_guard.generic_safe_no_accessory_evidence_answer_template_de');
}
public function getMeasurementEvidenceRuleTemplate(string $key): string
{
return $this->getRequiredString('measurement_evidence_guard.rule_templates.' . $key);
}
/**
* @return string[]
*/
public function getMeasurementEvidenceFinalRules(): array
{
return $this->getRequiredStringList('measurement_evidence_guard.final_rules');
}
public function getParameterParsingSplitPattern(): string
{
return $this->getRequiredString('parameter_parsing.split_pattern');
}
public function getParameterParsingTrimCharacters(): string
{
return $this->getRequiredString('parameter_parsing.trim_characters');
}
/**
* @return array<int, array<string, mixed>>
*/

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Knowledge\Retrieval;
use App\Config\LanguageCleanupConfig;
use App\Config\NdjsonHybridRetrieverConfig;
use App\Knowledge\ChunkManager;
@@ -11,7 +12,8 @@ final readonly class NdjsonChunkLookup
{
public function __construct(
private ChunkManager $chunkManager,
private NdjsonHybridRetrieverConfig $retrieverConfig
private NdjsonHybridRetrieverConfig $retrieverConfig,
private LanguageCleanupConfig $languageCleanupConfig
) {
}
@@ -416,7 +418,7 @@ final readonly class NdjsonChunkLookup
private function normalizeText(string $value): string
{
$value = mb_strtolower(trim($value), 'UTF-8');
$value = str_replace(['-', '/', '_'], ' ', $value);
$value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value);
$value = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;

View File

@@ -1689,7 +1689,7 @@ final readonly class NdjsonHybridRetriever implements RetrieverInterface
private function normalizeText(string $value): string
{
$value = mb_strtolower(trim($value), 'UTF-8');
$value = str_replace(['-', '/', '_'], ' ', $value);
$value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value);
$value = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Knowledge\Retrieval;
use App\Config\LanguageCleanupConfig;
use App\Knowledge\StopWords;
use Psr\Log\LoggerInterface;
use SQLite3;
@@ -18,6 +19,7 @@ final readonly class NdjsonKeywordRetriever
private string $projectDir,
private LoggerInterface $agentLogger,
private StopWords $stopWords,
private LanguageCleanupConfig $languageCleanupConfig,
) {
}
@@ -177,7 +179,7 @@ final readonly class NdjsonKeywordRetriever
private function normalizeText(string $value): string
{
$value = mb_strtolower(trim($value), 'UTF-8');
$value = str_replace(['-', '/', '_'], ' ', $value);
$value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value);
$value = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Knowledge\Retrieval;
use App\Config\LanguageCleanupConfig;
use App\Knowledge\StopWords;
use Psr\Log\LoggerInterface;
use SQLite3;
@@ -23,6 +24,7 @@ final readonly class NdjsonLexicalIndexBuilder
private string $projectDir,
private LoggerInterface $agentLogger,
private StopWords $stopWords,
private LanguageCleanupConfig $languageCleanupConfig,
) {
}
@@ -350,7 +352,7 @@ final readonly class NdjsonLexicalIndexBuilder
private function normalizeText(string $value): string
{
$value = mb_strtolower(trim($value), 'UTF-8');
$value = str_replace(['-', '/', '_'], ' ', $value);
$value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value);
$value = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace App\Knowledge\Retrieval;
use App\Config\LanguageCleanupConfig;
use App\Knowledge\StopWords;
final readonly class QueryCleaner
{
public function __construct(
private StopWords $stopWords
private StopWords $stopWords,
private LanguageCleanupConfig $languageCleanupConfig
) {
}
@@ -33,7 +35,7 @@ final readonly class QueryCleaner
$query = mb_strtolower($query, 'UTF-8');
// 2. Treat hyphens and slashes as word separators
$query = str_replace(['-', '/'], ' ', $query);
$query = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($query);
// 3. Remove special characters, but keep:
// - letters