diff --git a/RETRIEX_PRODUCTION_UI_V1_FIX2_README.txt b/RETRIEX_PRODUCTION_UI_V1_FIX2_README.txt new file mode 100644 index 0000000..9228e8d --- /dev/null +++ b/RETRIEX_PRODUCTION_UI_V1_FIX2_README.txt @@ -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 diff --git a/RETRIEX_PRODUCTION_UI_V1_README.txt b/RETRIEX_PRODUCTION_UI_V1_README.txt new file mode 100644 index 0000000..3f57dcc --- /dev/null +++ b/RETRIEX_PRODUCTION_UI_V1_README.txt @@ -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. diff --git a/config/retriex/agent.yaml b/config/retriex/agent.yaml index 43cd98b..908396b 100644 --- a/config/retriex/agent.yaml +++ b/config/retriex/agent.yaml @@ -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: ' diff --git a/public/assets/js/base.js b/public/assets/js/base.js index d0144c6..28285ac 100644 --- a/public/assets/js/base.js +++ b/public/assets/js/base.js @@ -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 = `${safeMessage}`; 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 = `${userMessage}`; raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`; - renderBubbleContent(bubble, raw); + finalizeStream(bubble, raw, {state: 'error'}); } else { bubble.innerHTML = `${userMessage}`; enhanceChatLinks(bubble); diff --git a/public/assets/styles/base.css b/public/assets/styles/base.css index 9606f4e..5fe4764 100644 --- a/public/assets/styles/base.css +++ b/public/assets/styles/base.css @@ -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; + } +} diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index c2b638f..2bf070e 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -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 = '
' + . '
RetrieX-Status
' + . '
' . htmlspecialchars($stageLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' + . '
' + . '' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . '' . htmlspecialchars($ragLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . '' . htmlspecialchars($shopLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . 'Beleglage: ' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . '
'; + + if ($sources !== []) { + $html .= '
Datenbasis
'; + + foreach ($sources as $source) { + $html .= '' . htmlspecialchars($source, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + } + + $html .= '
'; + } else { + $emptySourceLabel = $completed ? 'keine belastbare Datenbasis' : 'wird geprüft'; + $html .= '
Datenbasis
' + . htmlspecialchars($emptySourceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '
'; + } + + $html .= '
'; + + 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 = '
' + . '
Live-Shopdaten
' + . '
Shop-Ergebnisse
' + . '
' . htmlspecialchars($summary, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + + if ($query !== '') { + $html .= '
Ausgewertete Suchquery' + . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '
'; + } + + $html .= '
'; + + foreach ($visibleResults as $product) { + if (!$product instanceof ShopProductResult) { + continue; + } + + $html .= $this->buildShopProductCard($product, $query); + } + + $html .= '
'; + + 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 = '
' + . '
'; + + if ($url !== '') { + $html .= '' + . htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . ''; + } else { + $html .= htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + $html .= '
'; + $html .= '
Artikelnummer
' . htmlspecialchars($productNumber !== '' ? $productNumber : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + $html .= '
Preis
' . htmlspecialchars($price !== '' ? $price : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + $html .= '
Verfügbarkeit
' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + + if ($manufacturer !== '') { + $html .= '
Hersteller
' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + } + + $html .= '
' + . '
Relevanz' + . htmlspecialchars($relevance, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') + . '
' + . '
'; + + 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 = '
' + . '
Folgeaktionen
' + . '
Was möchtest du als Nächstes tun?
' + . '
'; + + foreach ($actions as [$label, $actionPrompt]) { + $html .= ''; + } + + $html .= '
'; + + return $html; + } + private function buildShopSearchMetaMessage( string $query, string $commerceIntent,