harden information getter services and optimize user msg

This commit is contained in:
team2
2026-04-28 07:22:29 +02:00
parent 79adf8f1df
commit 643d847ce2
6 changed files with 758 additions and 17 deletions

View File

@@ -0,0 +1,21 @@
RetrieX Production UI v1 - Fix 2
Zweck:
- Behebt die Anzeige von HTML-Badge-Spans als Text in der Datenbasis-Zeile.
- Behebt den visuellen Restzustand "Datenbasis wird geprüft" nach abgeschlossenen No-Treffer-Antworten.
- Ergaenzt eine finale abgeschlossene RetrieX-Statuskarte nach der Antwortgenerierung.
- Markiert die Statuskarte im Frontend beim Streamabschluss defensiv als abgeschlossen.
- Markiert die Statuskarte bei Streamfehlern defensiv als unterbrochen.
Geaenderte Dateien:
- src/Agent/AgentRunner.php
- public/assets/js/base.js
Einspielen:
- Dieses Hotfix-ZIP ueber den Stand mit Production-UI-v1 einspielen.
- Es enthaelt nur die fuer den visuellen Fehler notwendigen Dateien.
Geprueft:
- php -l src/Agent/AgentRunner.php
- node --check public/assets/js/base.js
- zip integrity test

View File

@@ -0,0 +1,42 @@
RetrieX Production UI v1 Patch
================================
Basis: aktualisierte rag-inprogess.zip aus dem Chat vom 28.04.2026.
Ziel:
- Produktions-UI abrunden, ohne Retrieval-, Scoring-, Prompt-, Job- oder SSE-Replay-Architektur umzubauen.
- Vorhandene Badges, Think-Statusmeldungen und Shop-Meta-Cards bleiben erhalten.
- Neue UI-Informationen werden als vorhandene HTML-Meta-Cards über den bestehenden Stream ausgegeben.
Enthaltene Änderungen:
- RetrieX-Statuskarte mit RAG-Treffern, Shop-Treffern, Beleglage und Datenbasis.
- Laufende Statusstufen: Antwort wird vorbereitet, RAG-Wissen wurde durchsucht, Shop-Suche wird vorbereitet, Shop wird durchsucht, Shop-Suche abgeschlossen, Antwort wird generiert, abgeschlossen.
- Deterministische Shopkarten aus ShopProductResult: Name, Artikelnummer, Preis, Verfügbarkeit, Hersteller, Link, Relevanz.
- Folgeaktions-Chips: Im Shop suchen, Nur Zubehör anzeigen, Nur Geräte anzeigen, Preis anzeigen, Technische Details anzeigen.
- Frontend-Deduplizierung aktualisiert bestehende Meta-Karten an ihrer ersten Position, statt die finale Karte ans Ende zu verschieben.
- Context-Hint im Browser ignoriert Meta-/Produkt-/Statuskarten, damit Folgefragen nicht durch UI-Texte verschmutzt werden.
- Loader-Text: Antwort wird vorbereitet...
Geänderte Dateien:
- src/Agent/AgentRunner.php
- public/assets/js/base.js
- public/assets/styles/base.css
- config/retriex/agent.yaml
Bewusst nicht geändert:
- Retrieval-/Vector-Logik
- Scoring
- PromptBuilder-Fachlogik
- Shop-Query-Repair-Logik
- SSE-Job-Replay-Protokoll
- Datenbank/Migrationen
Durchgeführte Prüfungen:
- php -l src/Agent/AgentRunner.php
- php -l src/Config/AgentRunnerConfig.php
- node --check public/assets/js/base.js
- YAML-Parse für config/retriex/agent.yaml
- Reflection-Smoke-Test für Statuskarte und Shopproduktkarten
Hinweis:
Der Patch enthält nur die geänderten Dateien. Zum Einspielen im Projektroot entpacken und bestehende Dateien überschreiben.

View File

@@ -16,7 +16,7 @@ parameters:
no_concrete_shop_query: 'Ich kann die Shop-Suche noch nicht belastbar auflösen. Bitte nenne das Produkt, den Messparameter oder das Zubehör etwas konkreter.'
fetch_search_data_template: 'Ich rufe Recherchedaten ab (type: %s)'
analyze_all_information: 'Ich analysiere alle Informationen...'
thinking_while_streaming: 'Denke nach...'
thinking_while_streaming: 'Antwort wird generiert...'
no_llm_data_received: '❌ Es wurden keine Daten vom LLM empfangen.'
generic_internal_error: '❌ Bei der Verarbeitung der Anfrage ist ein interner Fehler aufgetreten.'
debug_internal_error_prefix: '❌ Interner Fehler: '

View File

@@ -45,6 +45,19 @@ document.addEventListener('DOMContentLoaded', () => {
.filter(Boolean);
}
function extractAssistantContextText(bubble) {
if (!bubble) {
return '';
}
const clone = bubble.cloneNode(true);
clone.querySelectorAll('.think, .retriex-meta-card, .retriex-alert').forEach((element) => {
element.remove();
});
return normalizeContextHintText(clone.innerText || clone.textContent || '');
}
function isClientMetaOnlyShopPrompt(value) {
const tokens = tokenizeClientMetaGuardText(value);
@@ -62,9 +75,11 @@ document.addEventListener('DOMContentLoaded', () => {
}
function isNoConcreteShopResponse(value) {
return normalizeContextHintText(value)
.toLowerCase()
.includes('keine konkrete shop-suchanfrage erkannt');
const normalized = normalizeContextHintText(value).toLowerCase();
return normalized.includes('keine konkrete shop-suchanfrage erkannt')
|| normalized.includes('shop-suche noch nicht belastbar auflösen')
|| normalized.includes('shop-suche noch nicht belastbar aufloesen');
}
function rememberCompletedTurn(userPrompt, assistantText) {
@@ -130,7 +145,9 @@ document.addEventListener('DOMContentLoaded', () => {
messages.forEach((message) => {
const bubble = message.querySelector('.bubble');
const text = normalizeContextHintText(bubble?.innerText || bubble?.textContent || '');
const text = message.classList.contains('assistant')
? extractAssistantContextText(bubble)
: normalizeContextHintText(bubble?.innerText || bubble?.textContent || '');
if (!text) {
return;
@@ -220,12 +237,12 @@ document.addEventListener('DOMContentLoaded', () => {
}
function addLoader() {
return addMessage('assistant', 'AI is thinking…', 'loader');
return addMessage('assistant', 'Antwort wird vorbereitet…', 'loader');
}
function hasMeaningfulChildContent(element) {
return element.querySelector(
'img, table, pre, code, ul, ol, h1, h2, h3, h4, h5, h6, a, hr, .badge'
'img, table, pre, code, ul, ol, h1, h2, h3, h4, h5, h6, a, hr, .badge, .retriex-meta-card, .retriex-alert, .retriex-action-chip'
) !== null;
}
@@ -414,24 +431,50 @@ document.addEventListener('DOMContentLoaded', () => {
removeThinkSpansOnly(container);
}
function copyElementAttributes(target, source) {
Array.from(target.attributes).forEach((attribute) => {
target.removeAttribute(attribute.name);
});
Array.from(source.attributes).forEach((attribute) => {
target.setAttribute(attribute.name, attribute.value);
});
}
function deduplicateRetriexMetaCards(container) {
if (!container) {
return;
}
const cards = Array.from(container.querySelectorAll('.retriex-meta-card[data-retriex-meta-id]'));
const latestCardById = new Map();
cards.forEach((card) => {
latestCardById.set(card.getAttribute('data-retriex-meta-id'), card);
});
const cardsById = new Map();
cards.forEach((card) => {
const cardId = card.getAttribute('data-retriex-meta-id');
if (latestCardById.get(cardId) !== card) {
card.remove();
if (!cardId) {
return;
}
if (!cardsById.has(cardId)) {
cardsById.set(cardId, []);
}
cardsById.get(cardId).push(card);
});
cardsById.forEach((group) => {
if (group.length <= 1) {
return;
}
const firstCard = group[0];
const latestCard = group[group.length - 1];
firstCard.innerHTML = latestCard.innerHTML;
copyElementAttributes(firstCard, latestCard);
group.slice(1).forEach((card) => card.remove());
});
cleanupEmptyBlocks(container);
@@ -515,11 +558,45 @@ document.addEventListener('DOMContentLoaded', () => {
};
}
function finalizeStream(bubble, raw) {
function finalizeRetriexRunMetaCards(container, options = {}) {
if (!container) {
return;
}
const isError = options.state === 'error';
const titleText = isError ? 'Antwort wurde unterbrochen' : 'Abgeschlossen';
const statusText = isError ? 'Status: unterbrochen' : 'Status: abgeschlossen';
const emptySourceText = isError ? 'nicht vollständig geprüft' : 'keine belastbare Datenbasis';
container.querySelectorAll('.retriex-run-meta[data-retriex-meta-id="run-status"]').forEach((card) => {
card.setAttribute('data-retriex-meta-state', isError ? 'error' : 'completed');
const title = card.querySelector('.retriex-meta-card__title');
if (title) {
title.textContent = titleText;
}
const statusPill = card.querySelector('.retriex-meta-pill--status');
if (statusPill) {
statusPill.textContent = statusText;
}
const emptySource = card.querySelector('.retriex-source-overview__empty');
if (emptySource && emptySource.textContent.trim() === 'wird geprüft') {
emptySource.textContent = emptySourceText;
}
});
}
function finalizeStream(bubble, raw, options = {}) {
clearScheduledRender();
bubble.innerHTML = renderMarkdown(raw);
removeThinkSpansOnly(bubble);
deduplicateRetriexMetaCards(bubble);
finalizeRetriexRunMetaCards(bubble, options);
bubble.classList.remove('loader');
enhanceChatLinks(bubble);
scrollChatToBottom();
@@ -575,6 +652,24 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
chatEl?.addEventListener('click', (event) => {
const actionButton = event.target?.closest?.('.retriex-action-chip[data-retriex-action-prompt]');
if (!actionButton || state.isStreaming) {
return;
}
const actionPrompt = normalizeContextHintText(actionButton.getAttribute('data-retriex-action-prompt') || '');
if (!actionPrompt) {
return;
}
promptEl.value = actionPrompt;
promptEl.focus();
sendBtn.click();
});
loadHistory();
promptEl.addEventListener('keydown', (event) => {
@@ -631,7 +726,7 @@ document.addEventListener('DOMContentLoaded', () => {
const formattedMessage = `<em>${safeMessage}</em>`;
raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`;
finalizeStream(bubble, raw);
finalizeStream(bubble, raw, {state: 'error'});
};
const finishEventStream = () => {
@@ -781,7 +876,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (raw.trim() !== '') {
const formattedMessage = `<em>${userMessage}</em>`;
raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`;
renderBubbleContent(bubble, raw);
finalizeStream(bubble, raw, {state: 'error'});
} else {
bubble.innerHTML = `<em>${userMessage}</em>`;
enhanceChatLinks(bubble);

View File

@@ -565,3 +565,164 @@ span.think {
font-size: 0.92rem;
line-height: 1.45;
}
.retriex-run-meta[data-retriex-meta-state="completed"] {
border-color: rgba(25, 135, 84, 0.36);
}
.retriex-meta-pill--confidence {
background: rgba(255, 193, 7, 0.14);
color: #ffe8a1;
}
.retriex-meta-pill--status {
background: rgba(25, 135, 84, 0.15);
color: #b7f5cf;
}
.retriex-source-overview {
display: grid;
gap: 0.35rem;
margin-top: 0.15rem;
}
.retriex-source-overview > span,
.retriex-product-results__summary {
color: rgba(248, 249, 250, 0.72);
font-size: 0.82rem;
font-weight: 600;
}
.retriex-source-overview__badges,
.retriex-action-chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.retriex-source-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.16rem 0.55rem;
background: rgba(13, 202, 240, 0.14);
color: #b6effb;
font-size: 0.78rem;
font-weight: 700;
}
.retriex-source-overview__empty {
color: rgba(248, 249, 250, 0.58);
font-size: 0.86rem;
}
.retriex-product-results__summary {
margin-bottom: 0.65rem;
}
.retriex-meta-query--compact {
margin-bottom: 0.8rem;
}
.retriex-product-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.65rem;
}
.retriex-product-card {
display: grid;
gap: 0.55rem;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.045);
}
.retriex-product-card__title {
color: #f8f9fa;
font-weight: 800;
line-height: 1.3;
}
.retriex-product-card__title a {
color: inherit;
text-decoration: none;
}
.retriex-product-card__title a:hover {
text-decoration: underline;
}
.retriex-product-card__facts {
display: grid;
gap: 0.35rem;
margin: 0;
}
.retriex-product-card__facts div {
display: grid;
grid-template-columns: minmax(88px, 0.8fr) 1.2fr;
gap: 0.45rem;
}
.retriex-product-card__facts dt {
color: rgba(248, 249, 250, 0.58);
font-size: 0.76rem;
font-weight: 700;
}
.retriex-product-card__facts dd {
color: rgba(248, 249, 250, 0.9);
font-size: 0.8rem;
margin: 0;
word-break: break-word;
}
.retriex-product-card__relevance {
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 0.5rem;
color: rgba(248, 249, 250, 0.78);
font-size: 0.8rem;
line-height: 1.35;
}
.retriex-product-card__relevance span {
display: block;
color: #86b7fe;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 0.18rem;
}
.retriex-action-chip {
border: 1px solid rgba(134, 183, 254, 0.32);
border-radius: 999px;
padding: 0.34rem 0.72rem;
background: rgba(134, 183, 254, 0.11);
color: #d8e8ff;
font-size: 0.83rem;
font-weight: 700;
cursor: pointer;
}
.retriex-action-chip:hover,
.retriex-action-chip:focus-visible {
border-color: rgba(134, 183, 254, 0.68);
background: rgba(134, 183, 254, 0.2);
color: #ffffff;
}
@media (max-width: 576px) {
.retriex-product-grid {
grid-template-columns: 1fr;
}
.retriex-product-card__facts div {
grid-template-columns: 1fr;
gap: 0.1rem;
}
}

View File

@@ -51,7 +51,9 @@ final readonly class AgentRunner
$shopResults = [];
$primaryShopResults = [];
$knowledgeChunks = [];
$sources = [];
$urlContent = '';
$optimizedShopQuery = '';
$shopSearchQuery = '';
$shopSearchDisplayQuery = '';
@@ -77,6 +79,18 @@ final readonly class AgentRunner
// Additional context strategies can be added here later.
}
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Antwort wird vorbereitet',
ragCount: null,
shopCount: null,
shopCountMode: 'not_requested',
sourceLabels: $sources,
confidenceLabel: 'Beleglage wird geprüft'
),
'meta'
);
yield $this->systemMsg($this->agentRunnerConfig->getAnalyzeRequestMessage(), 'think');
yield $this->systemMsg($this->agentRunnerConfig->getCheckInternetSourcesMessage(), 'think');
@@ -101,6 +115,18 @@ final readonly class AgentRunner
$this->addSource($sources, $this->agentRunnerConfig->getRagKnowledgeSourceLabel());
}
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'RAG-Wissen wurde durchsucht',
ragCount: count($knowledgeChunks),
shopCount: null,
shopCountMode: 'not_requested',
sourceLabels: $sources,
confidenceLabel: $knowledgeChunks !== [] ? 'fachlich belegt' : 'noch keine belastbaren Treffer'
),
'meta'
);
if ($usedFollowUpRetrievalContext) {
$this->agentLogger->info('Knowledge retrieval used follow-up context', [
'userId' => $userId,
@@ -111,6 +137,18 @@ final readonly class AgentRunner
}
if ($this->isCommerceIntent($commerceIntent)) {
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Shop-Suche wird vorbereitet',
ragCount: count($knowledgeChunks),
shopCount: null,
shopCountMode: 'loading',
sourceLabels: $sources,
confidenceLabel: $knowledgeChunks !== [] ? 'fachlich belegt; Shopdaten werden geprüft' : 'Shopdaten werden geprüft'
),
'meta'
);
yield $this->systemMsg($this->agentRunnerConfig->getOptimizeSearchMessage(), 'think');
$commerceHistoryContext = $this->buildCommerceHistoryContext($userId, $requestContextHint);
@@ -145,6 +183,19 @@ final readonly class AgentRunner
$noConcreteShopQueryMessage = $this->agentRunnerConfig->getNoConcreteShopQueryMessage();
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Mehr Kontext nötig',
ragCount: count($knowledgeChunks),
shopCount: null,
shopCountMode: 'not_requested',
sourceLabels: $sources,
confidenceLabel: 'mehr Kontext nötig',
completed: true
),
'meta'
);
yield $this->systemMsg(
$noConcreteShopQueryMessage,
'info'
@@ -189,6 +240,18 @@ final readonly class AgentRunner
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
]);
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Shop wird durchsucht',
ragCount: count($knowledgeChunks),
shopCount: null,
shopCountMode: 'loading',
sourceLabels: $sources,
confidenceLabel: $knowledgeChunks !== [] ? 'fachlich belegt; Shopdaten werden geprüft' : 'Shopdaten werden geprüft'
),
'meta'
);
yield $this->systemMsg(
sprintf($this->agentRunnerConfig->getFetchSearchDataMessageTemplate(), $commerceIntent),
'think'
@@ -271,6 +334,24 @@ final readonly class AgentRunner
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: $knowledgeChunks !== [] || trim($urlContent) !== '',
isCommerceIntent: true,
shopSearchAttempted: $shopSearchAttempted,
hasShopResults: $shopResults !== [],
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure
)
),
'meta'
);
}
if ($shopResults !== []) {
@@ -318,6 +399,24 @@ final readonly class AgentRunner
]);
}
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Antwort wird generiert',
ragCount: count($knowledgeChunks),
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : ($shopSearchAttempted ? 'count' : 'not_requested'),
sourceLabels: $sources,
confidenceLabel: $this->resolveProductionUiConfidenceLabel(
hasKnowledge: $knowledgeChunks !== [] || trim($urlContent) !== '',
isCommerceIntent: $this->isCommerceIntent($commerceIntent),
shopSearchAttempted: $shopSearchAttempted,
hasShopResults: $shopResults !== [],
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure
)
),
'meta'
);
if ($sources !== []) {
yield $this->emitSources(
$sources,
@@ -338,6 +437,25 @@ final readonly class AgentRunner
$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: $primaryShopSearchHadSystemFailure ? 'unavailable' : ($shopSearchAttempted ? 'count' : 'not_requested'),
sourceLabels: $sources,
confidenceLabel: $this->resolveProductionUiConfidenceLabel(
hasKnowledge: $knowledgeChunks !== [] || trim($urlContent) !== '',
isCommerceIntent: $this->isCommerceIntent($commerceIntent),
shopSearchAttempted: $shopSearchAttempted,
hasShopResults: $shopResults !== [],
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure
),
completed: true
),
'meta'
);
if ($sources !== []) {
yield $this->emitSources(
$sources,
@@ -389,6 +507,18 @@ final readonly class AgentRunner
]);
$userErrorMessage = $this->buildUserErrorMessage($e);
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
stageLabel: 'Antwort wurde unterbrochen',
ragCount: count($knowledgeChunks),
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : ($shopSearchAttempted ? 'count' : 'not_requested'),
sourceLabels: $sources,
confidenceLabel: 'nicht abgeschlossen',
completed: true
),
'meta'
);
yield $this->systemMsg($userErrorMessage, 'err');
$historyResponse = $this->buildHistoryResponse('', array_merge(
@@ -1506,6 +1636,298 @@ final readonly class AgentRunner
return trim($value);
}
/**
* @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',
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 {
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">Live-Shopdaten</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,