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 = '
';
+
+ 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 = '';
+
+ 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 = ''
+ . '';
+ $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 = '';
+
+ return $html;
+ }
+
private function buildShopSearchMetaMessage(
string $query,
string $commerceIntent,