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.'
|
||||
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: '
|
||||
|
||||
@@ -45,6 +45,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractAssistantContextText(bubble) {
|
||||
if (!bubble) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const clone = bubble.cloneNode(true);
|
||||
clone.querySelectorAll('.think, .retriex-meta-card, .retriex-alert').forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
|
||||
return normalizeContextHintText(clone.innerText || clone.textContent || '');
|
||||
}
|
||||
|
||||
function isClientMetaOnlyShopPrompt(value) {
|
||||
const tokens = tokenizeClientMetaGuardText(value);
|
||||
|
||||
@@ -62,9 +75,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
function isNoConcreteShopResponse(value) {
|
||||
return normalizeContextHintText(value)
|
||||
.toLowerCase()
|
||||
.includes('keine konkrete shop-suchanfrage erkannt');
|
||||
const normalized = normalizeContextHintText(value).toLowerCase();
|
||||
|
||||
return normalized.includes('keine konkrete shop-suchanfrage erkannt')
|
||||
|| normalized.includes('shop-suche noch nicht belastbar auflösen')
|
||||
|| normalized.includes('shop-suche noch nicht belastbar aufloesen');
|
||||
}
|
||||
|
||||
function rememberCompletedTurn(userPrompt, assistantText) {
|
||||
@@ -130,7 +145,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
messages.forEach((message) => {
|
||||
const bubble = message.querySelector('.bubble');
|
||||
const text = normalizeContextHintText(bubble?.innerText || bubble?.textContent || '');
|
||||
const text = message.classList.contains('assistant')
|
||||
? extractAssistantContextText(bubble)
|
||||
: normalizeContextHintText(bubble?.innerText || bubble?.textContent || '');
|
||||
|
||||
if (!text) {
|
||||
return;
|
||||
@@ -220,12 +237,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
function addLoader() {
|
||||
return addMessage('assistant', 'AI is thinking…', 'loader');
|
||||
return addMessage('assistant', 'Antwort wird vorbereitet…', 'loader');
|
||||
}
|
||||
|
||||
function hasMeaningfulChildContent(element) {
|
||||
return element.querySelector(
|
||||
'img, table, pre, code, ul, ol, h1, h2, h3, h4, h5, h6, a, hr, .badge'
|
||||
'img, table, pre, code, ul, ol, h1, h2, h3, h4, h5, h6, a, hr, .badge, .retriex-meta-card, .retriex-alert, .retriex-action-chip'
|
||||
) !== null;
|
||||
}
|
||||
|
||||
@@ -414,24 +431,50 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
removeThinkSpansOnly(container);
|
||||
}
|
||||
|
||||
function copyElementAttributes(target, source) {
|
||||
Array.from(target.attributes).forEach((attribute) => {
|
||||
target.removeAttribute(attribute.name);
|
||||
});
|
||||
|
||||
Array.from(source.attributes).forEach((attribute) => {
|
||||
target.setAttribute(attribute.name, attribute.value);
|
||||
});
|
||||
}
|
||||
|
||||
function deduplicateRetriexMetaCards(container) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cards = Array.from(container.querySelectorAll('.retriex-meta-card[data-retriex-meta-id]'));
|
||||
const latestCardById = new Map();
|
||||
|
||||
cards.forEach((card) => {
|
||||
latestCardById.set(card.getAttribute('data-retriex-meta-id'), card);
|
||||
});
|
||||
const cardsById = new Map();
|
||||
|
||||
cards.forEach((card) => {
|
||||
const cardId = card.getAttribute('data-retriex-meta-id');
|
||||
|
||||
if (latestCardById.get(cardId) !== card) {
|
||||
card.remove();
|
||||
if (!cardId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cardsById.has(cardId)) {
|
||||
cardsById.set(cardId, []);
|
||||
}
|
||||
|
||||
cardsById.get(cardId).push(card);
|
||||
});
|
||||
|
||||
cardsById.forEach((group) => {
|
||||
if (group.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstCard = group[0];
|
||||
const latestCard = group[group.length - 1];
|
||||
|
||||
firstCard.innerHTML = latestCard.innerHTML;
|
||||
copyElementAttributes(firstCard, latestCard);
|
||||
|
||||
group.slice(1).forEach((card) => card.remove());
|
||||
});
|
||||
|
||||
cleanupEmptyBlocks(container);
|
||||
@@ -515,11 +558,45 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeStream(bubble, raw) {
|
||||
function finalizeRetriexRunMetaCards(container, options = {}) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isError = options.state === 'error';
|
||||
const titleText = isError ? 'Antwort wurde unterbrochen' : 'Abgeschlossen';
|
||||
const statusText = isError ? 'Status: unterbrochen' : 'Status: abgeschlossen';
|
||||
const emptySourceText = isError ? 'nicht vollständig geprüft' : 'keine belastbare Datenbasis';
|
||||
|
||||
container.querySelectorAll('.retriex-run-meta[data-retriex-meta-id="run-status"]').forEach((card) => {
|
||||
card.setAttribute('data-retriex-meta-state', isError ? 'error' : 'completed');
|
||||
|
||||
const title = card.querySelector('.retriex-meta-card__title');
|
||||
|
||||
if (title) {
|
||||
title.textContent = titleText;
|
||||
}
|
||||
|
||||
const statusPill = card.querySelector('.retriex-meta-pill--status');
|
||||
|
||||
if (statusPill) {
|
||||
statusPill.textContent = statusText;
|
||||
}
|
||||
|
||||
const emptySource = card.querySelector('.retriex-source-overview__empty');
|
||||
|
||||
if (emptySource && emptySource.textContent.trim() === 'wird geprüft') {
|
||||
emptySource.textContent = emptySourceText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeStream(bubble, raw, options = {}) {
|
||||
clearScheduledRender();
|
||||
bubble.innerHTML = renderMarkdown(raw);
|
||||
removeThinkSpansOnly(bubble);
|
||||
deduplicateRetriexMetaCards(bubble);
|
||||
finalizeRetriexRunMetaCards(bubble, options);
|
||||
bubble.classList.remove('loader');
|
||||
enhanceChatLinks(bubble);
|
||||
scrollChatToBottom();
|
||||
@@ -575,6 +652,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
chatEl?.addEventListener('click', (event) => {
|
||||
const actionButton = event.target?.closest?.('.retriex-action-chip[data-retriex-action-prompt]');
|
||||
|
||||
if (!actionButton || state.isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionPrompt = normalizeContextHintText(actionButton.getAttribute('data-retriex-action-prompt') || '');
|
||||
|
||||
if (!actionPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
promptEl.value = actionPrompt;
|
||||
promptEl.focus();
|
||||
sendBtn.click();
|
||||
});
|
||||
|
||||
loadHistory();
|
||||
|
||||
promptEl.addEventListener('keydown', (event) => {
|
||||
@@ -631,7 +726,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const formattedMessage = `<em>${safeMessage}</em>`;
|
||||
raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`;
|
||||
finalizeStream(bubble, raw);
|
||||
finalizeStream(bubble, raw, {state: 'error'});
|
||||
};
|
||||
|
||||
const finishEventStream = () => {
|
||||
@@ -781,7 +876,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (raw.trim() !== '') {
|
||||
const formattedMessage = `<em>${userMessage}</em>`;
|
||||
raw += raw.trim() === '' ? formattedMessage : `\n\n${formattedMessage}`;
|
||||
renderBubbleContent(bubble, raw);
|
||||
finalizeStream(bubble, raw, {state: 'error'});
|
||||
} else {
|
||||
bubble.innerHTML = `<em>${userMessage}</em>`;
|
||||
enhanceChatLinks(bubble);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,9 @@ final readonly class AgentRunner
|
||||
|
||||
$shopResults = [];
|
||||
$primaryShopResults = [];
|
||||
$knowledgeChunks = [];
|
||||
$sources = [];
|
||||
$urlContent = '';
|
||||
$optimizedShopQuery = '';
|
||||
$shopSearchQuery = '';
|
||||
$shopSearchDisplayQuery = '';
|
||||
@@ -77,6 +79,18 @@ final readonly class AgentRunner
|
||||
// Additional context strategies can be added here later.
|
||||
}
|
||||
|
||||
yield $this->systemMsg(
|
||||
$this->buildProductionUiMetaMessage(
|
||||
stageLabel: 'Antwort wird vorbereitet',
|
||||
ragCount: null,
|
||||
shopCount: null,
|
||||
shopCountMode: 'not_requested',
|
||||
sourceLabels: $sources,
|
||||
confidenceLabel: 'Beleglage wird geprüft'
|
||||
),
|
||||
'meta'
|
||||
);
|
||||
|
||||
yield $this->systemMsg($this->agentRunnerConfig->getAnalyzeRequestMessage(), 'think');
|
||||
yield $this->systemMsg($this->agentRunnerConfig->getCheckInternetSourcesMessage(), 'think');
|
||||
|
||||
@@ -101,6 +115,18 @@ final readonly class AgentRunner
|
||||
$this->addSource($sources, $this->agentRunnerConfig->getRagKnowledgeSourceLabel());
|
||||
}
|
||||
|
||||
yield $this->systemMsg(
|
||||
$this->buildProductionUiMetaMessage(
|
||||
stageLabel: 'RAG-Wissen wurde durchsucht',
|
||||
ragCount: count($knowledgeChunks),
|
||||
shopCount: null,
|
||||
shopCountMode: 'not_requested',
|
||||
sourceLabels: $sources,
|
||||
confidenceLabel: $knowledgeChunks !== [] ? 'fachlich belegt' : 'noch keine belastbaren Treffer'
|
||||
),
|
||||
'meta'
|
||||
);
|
||||
|
||||
if ($usedFollowUpRetrievalContext) {
|
||||
$this->agentLogger->info('Knowledge retrieval used follow-up context', [
|
||||
'userId' => $userId,
|
||||
@@ -111,6 +137,18 @@ final readonly class AgentRunner
|
||||
}
|
||||
|
||||
if ($this->isCommerceIntent($commerceIntent)) {
|
||||
yield $this->systemMsg(
|
||||
$this->buildProductionUiMetaMessage(
|
||||
stageLabel: 'Shop-Suche wird vorbereitet',
|
||||
ragCount: count($knowledgeChunks),
|
||||
shopCount: null,
|
||||
shopCountMode: 'loading',
|
||||
sourceLabels: $sources,
|
||||
confidenceLabel: $knowledgeChunks !== [] ? 'fachlich belegt; Shopdaten werden geprüft' : 'Shopdaten werden geprüft'
|
||||
),
|
||||
'meta'
|
||||
);
|
||||
|
||||
yield $this->systemMsg($this->agentRunnerConfig->getOptimizeSearchMessage(), 'think');
|
||||
|
||||
$commerceHistoryContext = $this->buildCommerceHistoryContext($userId, $requestContextHint);
|
||||
@@ -145,6 +183,19 @@ final readonly class AgentRunner
|
||||
|
||||
$noConcreteShopQueryMessage = $this->agentRunnerConfig->getNoConcreteShopQueryMessage();
|
||||
|
||||
yield $this->systemMsg(
|
||||
$this->buildProductionUiMetaMessage(
|
||||
stageLabel: 'Mehr Kontext nötig',
|
||||
ragCount: count($knowledgeChunks),
|
||||
shopCount: null,
|
||||
shopCountMode: 'not_requested',
|
||||
sourceLabels: $sources,
|
||||
confidenceLabel: 'mehr Kontext nötig',
|
||||
completed: true
|
||||
),
|
||||
'meta'
|
||||
);
|
||||
|
||||
yield $this->systemMsg(
|
||||
$noConcreteShopQueryMessage,
|
||||
'info'
|
||||
@@ -189,6 +240,18 @@ final readonly class AgentRunner
|
||||
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
||||
]);
|
||||
|
||||
yield $this->systemMsg(
|
||||
$this->buildProductionUiMetaMessage(
|
||||
stageLabel: 'Shop wird durchsucht',
|
||||
ragCount: count($knowledgeChunks),
|
||||
shopCount: null,
|
||||
shopCountMode: 'loading',
|
||||
sourceLabels: $sources,
|
||||
confidenceLabel: $knowledgeChunks !== [] ? 'fachlich belegt; Shopdaten werden geprüft' : 'Shopdaten werden geprüft'
|
||||
),
|
||||
'meta'
|
||||
);
|
||||
|
||||
yield $this->systemMsg(
|
||||
sprintf($this->agentRunnerConfig->getFetchSearchDataMessageTemplate(), $commerceIntent),
|
||||
'think'
|
||||
@@ -271,6 +334,24 @@ final readonly class AgentRunner
|
||||
if ($attemptedShopRepair) {
|
||||
$this->addSource($sources, $this->agentRunnerConfig->getExtendedShopSearchSourceLabel());
|
||||
}
|
||||
|
||||
yield $this->systemMsg(
|
||||
$this->buildProductionUiMetaMessage(
|
||||
stageLabel: $primaryShopSearchHadSystemFailure ? 'Shopdaten nicht verfügbar' : 'Shop-Suche abgeschlossen',
|
||||
ragCount: count($knowledgeChunks),
|
||||
shopCount: $primaryShopSearchHadSystemFailure ? null : count($shopResults),
|
||||
shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : 'count',
|
||||
sourceLabels: $sources,
|
||||
confidenceLabel: $this->resolveProductionUiConfidenceLabel(
|
||||
hasKnowledge: $knowledgeChunks !== [] || trim($urlContent) !== '',
|
||||
isCommerceIntent: true,
|
||||
shopSearchAttempted: $shopSearchAttempted,
|
||||
hasShopResults: $shopResults !== [],
|
||||
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure
|
||||
)
|
||||
),
|
||||
'meta'
|
||||
);
|
||||
}
|
||||
|
||||
if ($shopResults !== []) {
|
||||
@@ -318,6 +399,24 @@ final readonly class AgentRunner
|
||||
]);
|
||||
}
|
||||
|
||||
yield $this->systemMsg(
|
||||
$this->buildProductionUiMetaMessage(
|
||||
stageLabel: 'Antwort wird generiert',
|
||||
ragCount: count($knowledgeChunks),
|
||||
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
|
||||
shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : ($shopSearchAttempted ? 'count' : 'not_requested'),
|
||||
sourceLabels: $sources,
|
||||
confidenceLabel: $this->resolveProductionUiConfidenceLabel(
|
||||
hasKnowledge: $knowledgeChunks !== [] || trim($urlContent) !== '',
|
||||
isCommerceIntent: $this->isCommerceIntent($commerceIntent),
|
||||
shopSearchAttempted: $shopSearchAttempted,
|
||||
hasShopResults: $shopResults !== [],
|
||||
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure
|
||||
)
|
||||
),
|
||||
'meta'
|
||||
);
|
||||
|
||||
if ($sources !== []) {
|
||||
yield $this->emitSources(
|
||||
$sources,
|
||||
@@ -338,6 +437,25 @@ final readonly class AgentRunner
|
||||
|
||||
$fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer);
|
||||
|
||||
yield $this->systemMsg(
|
||||
$this->buildProductionUiMetaMessage(
|
||||
stageLabel: 'Abgeschlossen',
|
||||
ragCount: count($knowledgeChunks),
|
||||
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
|
||||
shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : ($shopSearchAttempted ? 'count' : 'not_requested'),
|
||||
sourceLabels: $sources,
|
||||
confidenceLabel: $this->resolveProductionUiConfidenceLabel(
|
||||
hasKnowledge: $knowledgeChunks !== [] || trim($urlContent) !== '',
|
||||
isCommerceIntent: $this->isCommerceIntent($commerceIntent),
|
||||
shopSearchAttempted: $shopSearchAttempted,
|
||||
hasShopResults: $shopResults !== [],
|
||||
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure
|
||||
),
|
||||
completed: true
|
||||
),
|
||||
'meta'
|
||||
);
|
||||
|
||||
if ($sources !== []) {
|
||||
yield $this->emitSources(
|
||||
$sources,
|
||||
@@ -389,6 +507,18 @@ final readonly class AgentRunner
|
||||
]);
|
||||
|
||||
$userErrorMessage = $this->buildUserErrorMessage($e);
|
||||
yield $this->systemMsg(
|
||||
$this->buildProductionUiMetaMessage(
|
||||
stageLabel: 'Antwort wurde unterbrochen',
|
||||
ragCount: count($knowledgeChunks),
|
||||
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
|
||||
shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : ($shopSearchAttempted ? 'count' : 'not_requested'),
|
||||
sourceLabels: $sources,
|
||||
confidenceLabel: 'nicht abgeschlossen',
|
||||
completed: true
|
||||
),
|
||||
'meta'
|
||||
);
|
||||
yield $this->systemMsg($userErrorMessage, 'err');
|
||||
|
||||
$historyResponse = $this->buildHistoryResponse('', array_merge(
|
||||
@@ -1506,6 +1636,298 @@ final readonly class AgentRunner
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $sourceLabels
|
||||
*/
|
||||
private function buildProductionUiMetaMessage(
|
||||
string $stageLabel,
|
||||
?int $ragCount,
|
||||
?int $shopCount,
|
||||
string $shopCountMode,
|
||||
array $sourceLabels,
|
||||
string $confidenceLabel,
|
||||
bool $completed = false
|
||||
): string {
|
||||
$state = $completed ? 'completed' : 'running';
|
||||
$ragLabel = $ragCount === null
|
||||
? 'RAG-Treffer: wird geprüft'
|
||||
: 'RAG-Treffer: ' . max(0, $ragCount);
|
||||
$shopLabel = match ($shopCountMode) {
|
||||
'count' => 'Shop-Treffer: ' . max(0, (int) $shopCount),
|
||||
'loading' => 'Shop-Treffer: wird geladen',
|
||||
'unavailable' => 'Shop-Treffer: nicht verfügbar',
|
||||
default => 'Shop-Treffer: nicht angefragt',
|
||||
};
|
||||
$statusLabel = $completed ? 'Status: abgeschlossen' : 'Status: läuft';
|
||||
$sources = $this->formatProductionUiSourceLabels($sourceLabels);
|
||||
|
||||
$html = '<div class="retriex-meta-card retriex-run-meta" data-retriex-meta-id="run-status" data-retriex-meta-state="'
|
||||
. htmlspecialchars($state, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '">'
|
||||
. '<div class="retriex-meta-card__eyebrow">RetrieX-Status</div>'
|
||||
. '<div class="retriex-meta-card__title">' . htmlspecialchars($stageLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
|
||||
. '<div class="retriex-meta-card__body">'
|
||||
. '<span class="retriex-meta-pill retriex-meta-pill--status">' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
||||
. '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($ragLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
||||
. '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($shopLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
||||
. '<span class="retriex-meta-pill retriex-meta-pill--confidence">Beleglage: ' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
|
||||
. '</div>';
|
||||
|
||||
if ($sources !== []) {
|
||||
$html .= '<div class="retriex-source-overview"><span>Datenbasis</span><div class="retriex-source-overview__badges">';
|
||||
|
||||
foreach ($sources as $source) {
|
||||
$html .= '<span class="retriex-source-chip">' . htmlspecialchars($source, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';
|
||||
}
|
||||
|
||||
$html .= '</div></div>';
|
||||
} else {
|
||||
$emptySourceLabel = $completed ? 'keine belastbare Datenbasis' : 'wird geprüft';
|
||||
$html .= '<div class="retriex-source-overview"><span>Datenbasis</span><div class="retriex-source-overview__empty">'
|
||||
. htmlspecialchars($emptySourceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '</div></div>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function resolveProductionUiConfidenceLabel(
|
||||
bool $hasKnowledge,
|
||||
bool $isCommerceIntent,
|
||||
bool $shopSearchAttempted,
|
||||
bool $hasShopResults,
|
||||
bool $shopSearchHadSystemFailure
|
||||
): string {
|
||||
if ($shopSearchHadSystemFailure) {
|
||||
return $hasKnowledge ? 'fachlich belegt; Shopdaten nicht verfügbar' : 'Shopdaten nicht verfügbar';
|
||||
}
|
||||
|
||||
if ($hasKnowledge && $hasShopResults) {
|
||||
return 'RAG + Shopdaten';
|
||||
}
|
||||
|
||||
if (!$hasKnowledge && $hasShopResults) {
|
||||
return 'nur Shopdaten';
|
||||
}
|
||||
|
||||
if ($hasKnowledge && $shopSearchAttempted) {
|
||||
return 'RAG-Wissen, keine Shop-Treffer';
|
||||
}
|
||||
|
||||
if ($hasKnowledge) {
|
||||
return 'fachlich belegt';
|
||||
}
|
||||
|
||||
if ($isCommerceIntent || $shopSearchAttempted) {
|
||||
return 'keine belastbaren Daten';
|
||||
}
|
||||
|
||||
return 'noch keine belastbaren Treffer';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $sourceLabels
|
||||
* @return string[]
|
||||
*/
|
||||
private function formatProductionUiSourceLabels(array $sourceLabels): array
|
||||
{
|
||||
$labels = [];
|
||||
|
||||
foreach ($sourceLabels as $label) {
|
||||
// Source labels are stored as badge HTML for the legacy "Genutzte Quellen" line.
|
||||
// The production UI data-basis chips must render plain labels, otherwise the
|
||||
// nested badge markup is escaped and shown as visible text.
|
||||
$label = $this->plainTextFromHtml((string) $label);
|
||||
|
||||
if ($label === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($label === $this->plainTextFromHtml($this->agentRunnerConfig->getShopSystemSourceLabel())) {
|
||||
$label = 'Live-Shopdaten';
|
||||
}
|
||||
|
||||
if (!in_array($label, $labels, true)) {
|
||||
$labels[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ShopProductResult[] $shopResults
|
||||
*/
|
||||
private function buildShopProductCardsMessage(array $shopResults, string $query, bool $usedRepair): string
|
||||
{
|
||||
$maxCards = 5;
|
||||
$visibleResults = array_slice($shopResults, 0, $maxCards);
|
||||
$totalCount = count($shopResults);
|
||||
$query = $this->normalizeOneLine($query);
|
||||
$summary = $totalCount . ' Shop-Treffer ausgewertet';
|
||||
|
||||
if ($totalCount > $maxCards) {
|
||||
$summary .= ' · Top ' . $maxCards . ' angezeigt';
|
||||
}
|
||||
|
||||
if ($usedRepair) {
|
||||
$summary .= ' · erweiterte Shopsuche genutzt';
|
||||
}
|
||||
|
||||
$html = '<div class="retriex-meta-card retriex-product-results" data-retriex-meta-id="shop-results" data-retriex-meta-state="completed">'
|
||||
. '<div class="retriex-meta-card__eyebrow">Live-Shopdaten</div>'
|
||||
. '<div class="retriex-meta-card__title">Shop-Ergebnisse</div>'
|
||||
. '<div class="retriex-product-results__summary">' . htmlspecialchars($summary, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>';
|
||||
|
||||
if ($query !== '') {
|
||||
$html .= '<div class="retriex-meta-query retriex-meta-query--compact"><span>Ausgewertete Suchquery</span><code>'
|
||||
. htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '</code></div>';
|
||||
}
|
||||
|
||||
$html .= '<div class="retriex-product-grid">';
|
||||
|
||||
foreach ($visibleResults as $product) {
|
||||
if (!$product instanceof ShopProductResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$html .= $this->buildShopProductCard($product, $query);
|
||||
}
|
||||
|
||||
$html .= '</div></div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildShopProductCard(ShopProductResult $product, string $query): string
|
||||
{
|
||||
$name = $this->normalizeOneLine($product->name) ?: 'Unbenanntes Produkt';
|
||||
$productNumber = $this->normalizeOneLine((string) $product->productNumber);
|
||||
$manufacturer = $this->normalizeOneLine((string) $product->manufacturer);
|
||||
$price = $this->normalizeOneLine((string) $product->price);
|
||||
$url = $this->normalizeOneLine((string) $product->url);
|
||||
$availability = $this->formatProductAvailability($product->available);
|
||||
$relevance = $this->buildProductRelevanceLabel($product, $query);
|
||||
|
||||
$html = '<article class="retriex-product-card">'
|
||||
. '<div class="retriex-product-card__title">';
|
||||
|
||||
if ($url !== '') {
|
||||
$html .= '<a href="' . htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '">'
|
||||
. htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '</a>';
|
||||
} else {
|
||||
$html .= htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
$html .= '</div><dl class="retriex-product-card__facts">';
|
||||
$html .= '<div><dt>Artikelnummer</dt><dd>' . htmlspecialchars($productNumber !== '' ? $productNumber : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
|
||||
$html .= '<div><dt>Preis</dt><dd>' . htmlspecialchars($price !== '' ? $price : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
|
||||
$html .= '<div><dt>Verfügbarkeit</dt><dd>' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
|
||||
|
||||
if ($manufacturer !== '') {
|
||||
$html .= '<div><dt>Hersteller</dt><dd>' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
|
||||
}
|
||||
|
||||
$html .= '</dl>'
|
||||
. '<div class="retriex-product-card__relevance"><span>Relevanz</span>'
|
||||
. htmlspecialchars($relevance, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '</div>'
|
||||
. '</article>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function formatProductAvailability(?bool $available): string
|
||||
{
|
||||
return match ($available) {
|
||||
true => 'verfügbar',
|
||||
false => 'nicht verfügbar',
|
||||
default => 'Shopstatus nicht übermittelt',
|
||||
};
|
||||
}
|
||||
|
||||
private function buildProductRelevanceLabel(ShopProductResult $product, string $query): string
|
||||
{
|
||||
$matchedQueries = [];
|
||||
|
||||
foreach ($product->matchedQueries as $matchedQuery) {
|
||||
$matchedQuery = $this->normalizeOneLine((string) $matchedQuery);
|
||||
|
||||
if ($matchedQuery !== '' && !in_array($matchedQuery, $matchedQueries, true)) {
|
||||
$matchedQueries[] = $matchedQuery;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedQueries !== []) {
|
||||
return 'Gefunden über: ' . implode(', ', array_slice($matchedQueries, 0, 3));
|
||||
}
|
||||
|
||||
foreach ($product->highlights as $highlight) {
|
||||
$highlight = $this->normalizeOneLine($this->plainTextFromHtml((string) $highlight));
|
||||
|
||||
if ($highlight !== '') {
|
||||
return 'Passender Shop-Hinweis: ' . mb_substr($highlight, 0, 140, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
$matchSource = $this->normalizeOneLine((string) $product->matchSource);
|
||||
|
||||
if ($matchSource !== '') {
|
||||
return 'Trefferquelle: ' . $matchSource;
|
||||
}
|
||||
|
||||
if ($query !== '') {
|
||||
return 'Passend zur Suchquery: ' . $query;
|
||||
}
|
||||
|
||||
return 'Aus den Live-Shopdaten übernommen';
|
||||
}
|
||||
|
||||
private function buildFollowUpActionsMessage(bool $isCommerceIntent, bool $hasShopResults, bool $hasKnowledge): string
|
||||
{
|
||||
if (!$isCommerceIntent && !$hasShopResults && !$hasKnowledge) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$actions = [];
|
||||
|
||||
if ($isCommerceIntent || $hasShopResults) {
|
||||
$actions[] = ['Im Shop suchen', 'Suche die aktuelle Produktauswahl im Shop.'];
|
||||
$actions[] = ['Nur Zubehör anzeigen', 'Zeige aus der aktuellen Produktauswahl nur Zubehör.'];
|
||||
$actions[] = ['Nur Geräte anzeigen', 'Zeige aus der aktuellen Produktauswahl nur Geräte.'];
|
||||
$actions[] = ['Preis anzeigen', 'Zeige mir die Preise der aktuell relevanten Produkte.'];
|
||||
}
|
||||
|
||||
if ($hasKnowledge || $hasShopResults) {
|
||||
$actions[] = ['Technische Details anzeigen', 'Zeige technische Details zur aktuellen Antwort.'];
|
||||
}
|
||||
|
||||
if ($actions === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = '<div class="retriex-meta-card retriex-followup-actions" data-retriex-meta-id="followup-actions" data-retriex-meta-state="completed">'
|
||||
. '<div class="retriex-meta-card__eyebrow">Folgeaktionen</div>'
|
||||
. '<div class="retriex-meta-card__title">Was möchtest du als Nächstes tun?</div>'
|
||||
. '<div class="retriex-action-chip-row">';
|
||||
|
||||
foreach ($actions as [$label, $actionPrompt]) {
|
||||
$html .= '<button type="button" class="retriex-action-chip" data-retriex-action-prompt="'
|
||||
. htmlspecialchars($actionPrompt, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '">'
|
||||
. htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '</button>';
|
||||
}
|
||||
|
||||
$html .= '</div></div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildShopSearchMetaMessage(
|
||||
string $query,
|
||||
string $commerceIntent,
|
||||
|
||||
Reference in New Issue
Block a user