harden information getter services and optimize user msg
This commit is contained in:
21
RETRIEX_PRODUCTION_UI_V1_FIX2_README.txt
Normal file
21
RETRIEX_PRODUCTION_UI_V1_FIX2_README.txt
Normal 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
|
||||||
42
RETRIEX_PRODUCTION_UI_V1_README.txt
Normal file
42
RETRIEX_PRODUCTION_UI_V1_README.txt
Normal 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.
|
||||||
@@ -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.'
|
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)'
|
fetch_search_data_template: 'Ich rufe Recherchedaten ab (type: %s)'
|
||||||
analyze_all_information: 'Ich analysiere alle Informationen...'
|
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.'
|
no_llm_data_received: '❌ Es wurden keine Daten vom LLM empfangen.'
|
||||||
generic_internal_error: '❌ Bei der Verarbeitung der Anfrage ist ein interner Fehler aufgetreten.'
|
generic_internal_error: '❌ Bei der Verarbeitung der Anfrage ist ein interner Fehler aufgetreten.'
|
||||||
debug_internal_error_prefix: '❌ Interner Fehler: '
|
debug_internal_error_prefix: '❌ Interner Fehler: '
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
.filter(Boolean);
|
.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) {
|
function isClientMetaOnlyShopPrompt(value) {
|
||||||
const tokens = tokenizeClientMetaGuardText(value);
|
const tokens = tokenizeClientMetaGuardText(value);
|
||||||
|
|
||||||
@@ -62,9 +75,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isNoConcreteShopResponse(value) {
|
function isNoConcreteShopResponse(value) {
|
||||||
return normalizeContextHintText(value)
|
const normalized = normalizeContextHintText(value).toLowerCase();
|
||||||
.toLowerCase()
|
|
||||||
.includes('keine konkrete shop-suchanfrage erkannt');
|
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) {
|
function rememberCompletedTurn(userPrompt, assistantText) {
|
||||||
@@ -130,7 +145,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
messages.forEach((message) => {
|
messages.forEach((message) => {
|
||||||
const bubble = message.querySelector('.bubble');
|
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) {
|
if (!text) {
|
||||||
return;
|
return;
|
||||||
@@ -220,12 +237,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addLoader() {
|
function addLoader() {
|
||||||
return addMessage('assistant', 'AI is thinking…', 'loader');
|
return addMessage('assistant', 'Antwort wird vorbereitet…', 'loader');
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasMeaningfulChildContent(element) {
|
function hasMeaningfulChildContent(element) {
|
||||||
return element.querySelector(
|
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;
|
) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,24 +431,50 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
removeThinkSpansOnly(container);
|
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) {
|
function deduplicateRetriexMetaCards(container) {
|
||||||
if (!container) {
|
if (!container) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards = Array.from(container.querySelectorAll('.retriex-meta-card[data-retriex-meta-id]'));
|
const cards = Array.from(container.querySelectorAll('.retriex-meta-card[data-retriex-meta-id]'));
|
||||||
const latestCardById = new Map();
|
const cardsById = new Map();
|
||||||
|
|
||||||
cards.forEach((card) => {
|
|
||||||
latestCardById.set(card.getAttribute('data-retriex-meta-id'), card);
|
|
||||||
});
|
|
||||||
|
|
||||||
cards.forEach((card) => {
|
cards.forEach((card) => {
|
||||||
const cardId = card.getAttribute('data-retriex-meta-id');
|
const cardId = card.getAttribute('data-retriex-meta-id');
|
||||||
|
|
||||||
if (latestCardById.get(cardId) !== card) {
|
if (!cardId) {
|
||||||
card.remove();
|
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);
|
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();
|
clearScheduledRender();
|
||||||
bubble.innerHTML = renderMarkdown(raw);
|
bubble.innerHTML = renderMarkdown(raw);
|
||||||
removeThinkSpansOnly(bubble);
|
removeThinkSpansOnly(bubble);
|
||||||
deduplicateRetriexMetaCards(bubble);
|
deduplicateRetriexMetaCards(bubble);
|
||||||
|
finalizeRetriexRunMetaCards(bubble, options);
|
||||||
bubble.classList.remove('loader');
|
bubble.classList.remove('loader');
|
||||||
enhanceChatLinks(bubble);
|
enhanceChatLinks(bubble);
|
||||||
scrollChatToBottom();
|
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();
|
loadHistory();
|
||||||
|
|
||||||
promptEl.addEventListener('keydown', (event) => {
|
promptEl.addEventListener('keydown', (event) => {
|
||||||
@@ -631,7 +726,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
const formattedMessage = `<em>${safeMessage}</em>`;
|
const formattedMessage = `<em>${safeMessage}</em>`;
|
||||||
raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`;
|
raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`;
|
||||||
finalizeStream(bubble, raw);
|
finalizeStream(bubble, raw, {state: 'error'});
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishEventStream = () => {
|
const finishEventStream = () => {
|
||||||
@@ -781,7 +876,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (raw.trim() !== '') {
|
if (raw.trim() !== '') {
|
||||||
const formattedMessage = `<em>${userMessage}</em>`;
|
const formattedMessage = `<em>${userMessage}</em>`;
|
||||||
raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`;
|
raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`;
|
||||||
renderBubbleContent(bubble, raw);
|
finalizeStream(bubble, raw, {state: 'error'});
|
||||||
} else {
|
} else {
|
||||||
bubble.innerHTML = `<em>${userMessage}</em>`;
|
bubble.innerHTML = `<em>${userMessage}</em>`;
|
||||||
enhanceChatLinks(bubble);
|
enhanceChatLinks(bubble);
|
||||||
|
|||||||
@@ -565,3 +565,164 @@ span.think {
|
|||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
line-height: 1.45;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ final readonly class AgentRunner
|
|||||||
|
|
||||||
$shopResults = [];
|
$shopResults = [];
|
||||||
$primaryShopResults = [];
|
$primaryShopResults = [];
|
||||||
|
$knowledgeChunks = [];
|
||||||
$sources = [];
|
$sources = [];
|
||||||
|
$urlContent = '';
|
||||||
$optimizedShopQuery = '';
|
$optimizedShopQuery = '';
|
||||||
$shopSearchQuery = '';
|
$shopSearchQuery = '';
|
||||||
$shopSearchDisplayQuery = '';
|
$shopSearchDisplayQuery = '';
|
||||||
@@ -77,6 +79,18 @@ final readonly class AgentRunner
|
|||||||
// Additional context strategies can be added here later.
|
// 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->getAnalyzeRequestMessage(), 'think');
|
||||||
yield $this->systemMsg($this->agentRunnerConfig->getCheckInternetSourcesMessage(), 'think');
|
yield $this->systemMsg($this->agentRunnerConfig->getCheckInternetSourcesMessage(), 'think');
|
||||||
|
|
||||||
@@ -101,6 +115,18 @@ final readonly class AgentRunner
|
|||||||
$this->addSource($sources, $this->agentRunnerConfig->getRagKnowledgeSourceLabel());
|
$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) {
|
if ($usedFollowUpRetrievalContext) {
|
||||||
$this->agentLogger->info('Knowledge retrieval used follow-up context', [
|
$this->agentLogger->info('Knowledge retrieval used follow-up context', [
|
||||||
'userId' => $userId,
|
'userId' => $userId,
|
||||||
@@ -111,6 +137,18 @@ final readonly class AgentRunner
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->isCommerceIntent($commerceIntent)) {
|
if ($this->isCommerceIntent($commerceIntent)) {
|
||||||
|
yield $this->systemMsg(
|
||||||
|
$this->buildProductionUiMetaMessage(
|
||||||
|
stageLabel: 'Shop-Suche wird vorbereitet',
|
||||||
|
ragCount: count($knowledgeChunks),
|
||||||
|
shopCount: null,
|
||||||
|
shopCountMode: 'loading',
|
||||||
|
sourceLabels: $sources,
|
||||||
|
confidenceLabel: $knowledgeChunks !== [] ? 'fachlich belegt; Shopdaten werden geprüft' : 'Shopdaten werden geprüft'
|
||||||
|
),
|
||||||
|
'meta'
|
||||||
|
);
|
||||||
|
|
||||||
yield $this->systemMsg($this->agentRunnerConfig->getOptimizeSearchMessage(), 'think');
|
yield $this->systemMsg($this->agentRunnerConfig->getOptimizeSearchMessage(), 'think');
|
||||||
|
|
||||||
$commerceHistoryContext = $this->buildCommerceHistoryContext($userId, $requestContextHint);
|
$commerceHistoryContext = $this->buildCommerceHistoryContext($userId, $requestContextHint);
|
||||||
@@ -145,6 +183,19 @@ final readonly class AgentRunner
|
|||||||
|
|
||||||
$noConcreteShopQueryMessage = $this->agentRunnerConfig->getNoConcreteShopQueryMessage();
|
$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(
|
yield $this->systemMsg(
|
||||||
$noConcreteShopQueryMessage,
|
$noConcreteShopQueryMessage,
|
||||||
'info'
|
'info'
|
||||||
@@ -189,6 +240,18 @@ final readonly class AgentRunner
|
|||||||
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
'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(
|
yield $this->systemMsg(
|
||||||
sprintf($this->agentRunnerConfig->getFetchSearchDataMessageTemplate(), $commerceIntent),
|
sprintf($this->agentRunnerConfig->getFetchSearchDataMessageTemplate(), $commerceIntent),
|
||||||
'think'
|
'think'
|
||||||
@@ -271,6 +334,24 @@ final readonly class AgentRunner
|
|||||||
if ($attemptedShopRepair) {
|
if ($attemptedShopRepair) {
|
||||||
$this->addSource($sources, $this->agentRunnerConfig->getExtendedShopSearchSourceLabel());
|
$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 !== []) {
|
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 !== []) {
|
if ($sources !== []) {
|
||||||
yield $this->emitSources(
|
yield $this->emitSources(
|
||||||
$sources,
|
$sources,
|
||||||
@@ -338,6 +437,25 @@ final readonly class AgentRunner
|
|||||||
|
|
||||||
$fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer);
|
$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 !== []) {
|
if ($sources !== []) {
|
||||||
yield $this->emitSources(
|
yield $this->emitSources(
|
||||||
$sources,
|
$sources,
|
||||||
@@ -389,6 +507,18 @@ final readonly class AgentRunner
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$userErrorMessage = $this->buildUserErrorMessage($e);
|
$userErrorMessage = $this->buildUserErrorMessage($e);
|
||||||
|
yield $this->systemMsg(
|
||||||
|
$this->buildProductionUiMetaMessage(
|
||||||
|
stageLabel: 'Antwort wurde unterbrochen',
|
||||||
|
ragCount: count($knowledgeChunks),
|
||||||
|
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
|
||||||
|
shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : ($shopSearchAttempted ? 'count' : 'not_requested'),
|
||||||
|
sourceLabels: $sources,
|
||||||
|
confidenceLabel: 'nicht abgeschlossen',
|
||||||
|
completed: true
|
||||||
|
),
|
||||||
|
'meta'
|
||||||
|
);
|
||||||
yield $this->systemMsg($userErrorMessage, 'err');
|
yield $this->systemMsg($userErrorMessage, 'err');
|
||||||
|
|
||||||
$historyResponse = $this->buildHistoryResponse('', array_merge(
|
$historyResponse = $this->buildHistoryResponse('', array_merge(
|
||||||
@@ -1506,6 +1636,298 @@ final readonly class AgentRunner
|
|||||||
return trim($value);
|
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(
|
private function buildShopSearchMetaMessage(
|
||||||
string $query,
|
string $query,
|
||||||
string $commerceIntent,
|
string $commerceIntent,
|
||||||
|
|||||||
Reference in New Issue
Block a user