harden information getter services and optimize user msg

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

View File

@@ -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,