last update before full new 1.5.0

This commit is contained in:
team2
2026-04-29 20:05:16 +02:00
parent 19c0f612dc
commit e39a57e00b
5 changed files with 423 additions and 107 deletions

View File

@@ -52,6 +52,7 @@ final readonly class AgentRunner
$shopResults = [];
$primaryShopResults = [];
$knowledgeChunks = [];
$knowledgeEvidenceState = 'none';
$sources = [];
$optimizedShopQuery = '';
$shopSearchQuery = '';
@@ -105,6 +106,7 @@ final readonly class AgentRunner
$usedFollowUpRetrievalContext = $knowledgeRetrievalPrompt !== $prompt;
$knowledgeChunks = $this->retriever->retrieve($knowledgeRetrievalPrompt);
$knowledgeEvidenceState = $this->resolveKnowledgeEvidenceState($prompt, $knowledgeChunks, $urlContent);
if ($knowledgeChunks !== []) {
$this->addSource($sources, $this->agentRunnerConfig->getRagKnowledgeSourceLabel());
}
@@ -116,7 +118,7 @@ final readonly class AgentRunner
shopCount: null,
shopCountMode: 'not_requested',
sourceLabels: $sources,
confidenceLabel: $knowledgeChunks !== [] ? 'fachlich belegt' : 'noch keine belastbaren Treffer'
confidenceLabel: $this->resolveRagEvidenceConfidenceLabel($knowledgeEvidenceState)
),
'meta'
);
@@ -138,7 +140,7 @@ final readonly class AgentRunner
shopCount: null,
shopCountMode: 'loading',
sourceLabels: $sources,
confidenceLabel: $knowledgeChunks !== [] ? 'fachlich belegt; Shopdaten werden geprüft' : 'Shopdaten werden geprüft'
confidenceLabel: $this->resolveRagEvidenceShopCheckConfidenceLabel($knowledgeEvidenceState)
),
'meta'
);
@@ -241,7 +243,7 @@ final readonly class AgentRunner
shopCount: null,
shopCountMode: 'loading',
sourceLabels: $sources,
confidenceLabel: $knowledgeChunks !== [] ? 'fachlich belegt; Shopdaten werden geprüft' : 'Shopdaten werden geprüft'
confidenceLabel: $this->resolveRagEvidenceShopCheckConfidenceLabel($knowledgeEvidenceState)
),
'meta'
);
@@ -274,6 +276,17 @@ final readonly class AgentRunner
$shopUnavailableMessage,
'err'
);
yield $this->systemMsg(
$this->buildShopSearchMetaMessage(
query: $shopSearchDisplayQuery !== '' ? $shopSearchDisplayQuery : $shopSearchQuery,
commerceIntent: $commerceIntent,
usedOptimizedQuery: $shopSearchUsedOptimizedQuery,
originalQuery: $shopSearchQuery,
completed: true,
unavailable: true
),
'meta'
);
$historyNotices[] = $this->buildHistoryNotice(
'Shopdaten konnten nicht geladen werden',
$primaryShopSearchFailureReason
@@ -337,7 +350,7 @@ final readonly class AgentRunner
shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : 'count',
sourceLabels: $sources,
confidenceLabel: $this->resolveProductionUiConfidenceLabel(
hasKnowledge: $knowledgeChunks !== [] || trim($urlContent) !== '',
hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState),
isCommerceIntent: true,
shopSearchAttempted: $shopSearchAttempted,
hasShopResults: $shopResults !== [],
@@ -363,7 +376,8 @@ final readonly class AgentRunner
fullContext: $forceFullContext,
swagFullOutPut: $optimizedShopQuery,
commerceSearchAttempted: $shopSearchAttempted,
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
knowledgeEvidenceState: $knowledgeEvidenceState
);
if ($this->debug && $this->logPrompt) {
@@ -401,7 +415,7 @@ final readonly class AgentRunner
shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : ($shopSearchAttempted ? 'count' : 'not_requested'),
sourceLabels: $sources,
confidenceLabel: $this->resolveProductionUiConfidenceLabel(
hasKnowledge: $knowledgeChunks !== [] || trim($urlContent) !== '',
hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState),
isCommerceIntent: $this->isCommerceIntent($commerceIntent),
shopSearchAttempted: $shopSearchAttempted,
hasShopResults: $shopResults !== [],
@@ -426,7 +440,8 @@ final readonly class AgentRunner
commerceIntent: $commerceIntent,
shopSearchAttempted: $shopSearchAttempted,
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
shopSearchFailureReason: $primaryShopSearchFailureReason ?? null
shopSearchFailureReason: $primaryShopSearchFailureReason ?? null,
knowledgeEvidenceState: $knowledgeEvidenceState
);
$fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer);
@@ -439,7 +454,7 @@ final readonly class AgentRunner
shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : ($shopSearchAttempted ? 'count' : 'not_requested'),
sourceLabels: $sources,
confidenceLabel: $this->resolveProductionUiConfidenceLabel(
hasKnowledge: $knowledgeChunks !== [] || trim($urlContent) !== '',
hasKnowledge: $this->isDirectKnowledgeEvidence($knowledgeEvidenceState),
isCommerceIntent: $this->isCommerceIntent($commerceIntent),
shopSearchAttempted: $shopSearchAttempted,
hasShopResults: $shopResults !== [],
@@ -1413,9 +1428,10 @@ final readonly class AgentRunner
string $commerceIntent,
bool $shopSearchAttempted,
bool $shopSearchHadSystemFailure,
?string $shopSearchFailureReason
?string $shopSearchFailureReason,
string $knowledgeEvidenceState = 'unknown'
): string {
$hasKnowledge = $knowledgeChunks !== [] || trim($urlContent) !== '';
$hasKnowledge = $this->isDirectKnowledgeEvidence($knowledgeEvidenceState) || ($knowledgeEvidenceState === 'unknown' && ($knowledgeChunks !== [] || trim($urlContent) !== ''));
$hasShopResults = $shopResults !== [];
$isCommerceIntent = $this->isCommerceIntent($commerceIntent);
@@ -1636,6 +1652,172 @@ final readonly class AgentRunner
}
/**
* Distinguish semantic nearest-neighbor retrieval hits from direct factual evidence.
*
* Vector retrieval can return useful context even when the essential user term is not
* present. Those hits should stay visible as RAG hits, but they must not be counted as
* "fachlich belegt" unless at least one salient request term or configured synonym
* appears in the retrieved knowledge or user-provided URL content.
*
* @param string[] $knowledgeChunks
*/
private function resolveKnowledgeEvidenceState(string $prompt, array $knowledgeChunks, string $urlContent): string
{
if ($knowledgeChunks === [] && trim($urlContent) === '') {
return 'none';
}
if (trim($urlContent) !== '') {
return 'direct';
}
$needles = $this->buildRagEvidenceNeedles($prompt);
if ($needles === []) {
// No meaningful term could be extracted. Preserve the previous behavior for
// very short follow-ups instead of hiding potentially valid context.
return 'direct';
}
$haystack = $this->normalizeRagEvidenceText(implode("\n\n", array_map('strval', $knowledgeChunks)));
foreach ($needles as $needleGroup) {
foreach ($needleGroup as $needle) {
if ($this->containsRagEvidenceTerm($haystack, $needle)) {
return 'direct';
}
}
}
return 'weak';
}
private function isDirectKnowledgeEvidence(string $knowledgeEvidenceState): bool
{
return $knowledgeEvidenceState === 'direct';
}
private function resolveRagEvidenceConfidenceLabel(string $knowledgeEvidenceState): string
{
return match ($knowledgeEvidenceState) {
'direct' => 'fachlich belegt',
'weak' => 'RAG-Näherungstreffer, kein direkter Fachbeleg',
default => 'noch keine belastbaren Treffer',
};
}
private function resolveRagEvidenceShopCheckConfidenceLabel(string $knowledgeEvidenceState): string
{
return match ($knowledgeEvidenceState) {
'direct' => 'fachlich belegt; Shopdaten werden geprüft',
'weak' => 'RAG-Näherungstreffer; Shopdaten werden geprüft',
default => 'Shopdaten werden geprüft',
};
}
/**
* @return array<int, string[]>
*/
private function buildRagEvidenceNeedles(string $prompt): array
{
$normalizedPrompt = $this->normalizeRagEvidenceText($prompt);
$stopTerms = [];
foreach ($this->agentRunnerConfig->getRagEvidenceStopTerms() as $term) {
$term = $this->normalizeRagEvidenceText($term);
if ($term !== '') {
$stopTerms[$term] = true;
}
}
preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}\-]{1,}/u', $normalizedPrompt, $matches);
$tokens = $matches[0] ?? [];
$groups = [];
$synonyms = $this->normalizedRagEvidenceSynonyms();
foreach ($tokens as $token) {
$token = trim((string) $token);
if ($token === '' || isset($stopTerms[$token]) || mb_strlen($token, 'UTF-8') < 3) {
continue;
}
$group = $synonyms[$token] ?? [$token];
$group = array_values(array_unique(array_filter(array_map(
fn (string $item): string => $this->normalizeRagEvidenceText($item),
$group
))));
if ($group !== []) {
$groups[] = $group;
}
}
return $groups;
}
/**
* @return array<string, string[]>
*/
private function normalizedRagEvidenceSynonyms(): array
{
$out = [];
foreach ($this->agentRunnerConfig->getRagEvidenceSynonyms() as $key => $items) {
$key = $this->normalizeRagEvidenceText((string) $key);
if ($key === '') {
continue;
}
$terms = [];
foreach ($items as $item) {
$item = $this->normalizeRagEvidenceText((string) $item);
if ($item !== '' && !in_array($item, $terms, true)) {
$terms[] = $item;
}
}
if (!in_array($key, $terms, true)) {
$terms[] = $key;
}
$out[$key] = $terms;
}
return $out;
}
private function containsRagEvidenceTerm(string $haystack, string $needle): bool
{
$needle = $this->normalizeRagEvidenceText($needle);
if ($haystack === '' || $needle === '') {
return false;
}
$pattern = '/(?<![\p{L}\p{N}])' . preg_quote($needle, '/') . '(?![\p{L}\p{N}])/u';
return preg_match($pattern, $haystack) === 1;
}
private function normalizeRagEvidenceText(string $value): string
{
$value = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$value = mb_strtolower($value, 'UTF-8');
$value = str_replace(['', '', '', '', '—'], '-', $value);
$value = strtr($value, [
'ä' => 'ae',
'ö' => 'oe',
'ü' => 'ue',
'ß' => 'ss',
]);
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
return trim($value);
}
/**
* @param string[] $sources
*/
@@ -1814,10 +1996,6 @@ final readonly class AgentRunner
private function formatProductionUiSourceLabels(array $sourceLabels): array
{
$labels = [];
$seen = [];
$shopSystemKey = $this->canonicalProductionUiSourceLabelKey(
$this->plainTextFromHtml($this->agentRunnerConfig->getShopSystemSourceLabel())
);
foreach ($sourceLabels as $label) {
// Source labels are stored as badge HTML for the legacy "Genutzte Quellen" line.
@@ -1829,35 +2007,18 @@ final readonly class AgentRunner
continue;
}
$key = $this->canonicalProductionUiSourceLabelKey($label);
if ($key === $shopSystemKey || $key === 'liveshopdaten') {
if ($label === $this->plainTextFromHtml($this->agentRunnerConfig->getShopSystemSourceLabel())) {
$label = 'Live-Shopdaten';
$key = 'liveshopdaten';
}
if (isset($seen[$key])) {
continue;
if (!in_array($label, $labels, true)) {
$labels[] = $label;
}
$seen[$key] = true;
$labels[] = $label;
}
return $labels;
}
private function canonicalProductionUiSourceLabelKey(string $label): string
{
$label = html_entity_decode(strip_tags($label), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$label = str_replace(["\xc2\xa0", '', '', '', '', '—'], [' ', '-', '-', '-', '-', '-'], $label);
$label = preg_replace('/\s+/u', ' ', $label) ?? $label;
$label = mb_strtolower(trim($label), 'UTF-8');
$label = preg_replace('/[^\p{L}\p{N}]+/u', '', $label) ?? $label;
return $label;
}
/**
* @param ShopProductResult[] $shopResults
*/
@@ -2037,7 +2198,8 @@ final readonly class AgentRunner
?int $resultCount = null,
bool $completed = false,
bool $attemptedRepair = false,
bool $usedRepair = false
bool $usedRepair = false,
bool $unavailable = false
): string {
$query = $this->normalizeOneLine($query);
$originalQuery = $this->normalizeOneLine($originalQuery);
@@ -2048,11 +2210,13 @@ final readonly class AgentRunner
$queryModeLabel = $usedOptimizedQuery ? 'optimiert' : 'direkt';
$intentLabel = $commerceIntent !== '' ? $commerceIntent : 'commerce';
$title = $completed ? 'Shop-Suche abgeschlossen' : 'Shop-Suche wird ausgeführt';
$title = $unavailable ? 'Shopdaten nicht verfügbar' : ($completed ? 'Shop-Suche abgeschlossen' : 'Shop-Suche wird ausgeführt');
$statusLabel = $completed ? 'Status: abgeschlossen' : 'Status: läuft';
$resultLabel = $resultCount === null
? 'Shoptreffer: wird geladen'
: 'Shoptreffer: ' . max(0, $resultCount);
$resultLabel = $unavailable
? 'Shoptreffer: nicht verfügbar'
: ($resultCount === null
? 'Shoptreffer: wird geladen'
: 'Shoptreffer: ' . max(0, $resultCount));
$state = $completed ? 'completed' : 'running';
$resultCountAttribute = $resultCount === null ? '' : (string) max(0, $resultCount);
$repairLabel = '';

View File

@@ -42,7 +42,8 @@ final readonly class PromptBuilder
?bool $fullContext = false,
?string $swagFullOutPut = '',
bool $commerceSearchAttempted = false,
bool $shopSearchHadSystemFailure = false
bool $shopSearchHadSystemFailure = false,
string $knowledgeEvidenceState = 'unknown'
): string {
$prompt = $this->normalizeBlockText($prompt);
$urlContent = $this->normalizeBlockText($urlContent);
@@ -57,7 +58,8 @@ final readonly class PromptBuilder
hasKnowledge: $hasKnowledge,
hasShopResults: $hasShopResults,
commerceSearchAttempted: $commerceSearchAttempted,
shopSearchHadSystemFailure: $shopSearchHadSystemFailure
shopSearchHadSystemFailure: $shopSearchHadSystemFailure,
knowledgeEvidenceState: $knowledgeEvidenceState
);
$systemBlock = $this->buildSystemBlock();
@@ -275,21 +277,31 @@ final readonly class PromptBuilder
bool $hasKnowledge,
bool $hasShopResults,
bool $commerceSearchAttempted,
bool $shopSearchHadSystemFailure
bool $shopSearchHadSystemFailure,
string $knowledgeEvidenceState = 'unknown'
): string {
if ($shopSearchHadSystemFailure && !$hasKnowledge) {
return 'shopdaten_nicht_verfuegbar';
$hasDirectKnowledgeEvidence = $knowledgeEvidenceState === 'direct' || $knowledgeEvidenceState === 'unknown' && $hasKnowledge;
$hasWeakKnowledgeEvidence = $knowledgeEvidenceState === 'weak';
if ($shopSearchHadSystemFailure && !$hasDirectKnowledgeEvidence) {
return $hasWeakKnowledgeEvidence
? 'semantische_rag_treffer_kein_direkter_fachbeleg_shopdaten_nicht_verfuegbar'
: 'shopdaten_nicht_verfuegbar';
}
if ($hasKnowledge && !$hasShopResults) {
if ($hasWeakKnowledgeEvidence && !$hasShopResults) {
return 'semantische_rag_treffer_kein_direkter_fachbeleg';
}
if ($hasDirectKnowledgeEvidence && !$hasShopResults) {
return 'sicher_beantwortbar';
}
if ($hasKnowledge && $hasShopResults) {
if ($hasDirectKnowledgeEvidence && $hasShopResults) {
return 'wahrscheinlich_beantwortbar';
}
if (!$hasKnowledge && $hasShopResults) {
if (!$hasDirectKnowledgeEvidence && $hasShopResults) {
return 'nur_shop_treffer_kein_belastbares_fachwissen';
}
@@ -540,13 +552,11 @@ final readonly class PromptBuilder
: $this->config->getShopAvailabilityNoLabel());
}
if (!$suppressCommercialFields) {
foreach ($product->highlights as $highlight) {
$highlight = $this->normalizeBlockText((string) $highlight);
foreach ($product->highlights as $highlight) {
$highlight = $this->normalizeBlockText((string) $highlight);
if ($highlight !== '') {
$entryParts[] = $this->config->getShopHighlightPrefix() . $highlight;
}
if ($highlight !== '') {
$entryParts[] = $this->config->getShopHighlightPrefix() . $highlight;
}
}

View File

@@ -25,6 +25,56 @@ final class AgentRunnerConfig
'pockettester',
];
private const RAG_EVIDENCE_STOP_TERMS = [
'suche',
'suchen',
'finde',
'finden',
'zeige',
'einen',
'eine',
'einem',
'einer',
'der',
'die',
'das',
'den',
'dem',
'des',
'für',
'fuer',
'mit',
'ohne',
'und',
'oder',
'kann',
'können',
'koennen',
'messen',
'messung',
'tester',
'testgerät',
'testgeraet',
'gerät',
'geraet',
'messgerät',
'messgeraet',
'produkt',
'produkte',
'artikel',
'shop',
];
private const RAG_EVIDENCE_SYNONYMS = [
'salinität' => ['salinität', 'salinitaet', 'salinity', 'salzgehalt', 'tds', 'leitfähigkeit', 'leitfaehigkeit'],
'salinitaet' => ['salinität', 'salinitaet', 'salinity', 'salzgehalt', 'tds', 'leitfähigkeit', 'leitfaehigkeit'],
'salinity' => ['salinität', 'salinitaet', 'salinity', 'salzgehalt', 'tds', 'leitfähigkeit', 'leitfaehigkeit'],
'redox' => ['redox', 'orp', 'oxidations-reduktionspotential', 'oxidations reduktionspotential'],
'orp' => ['redox', 'orp', 'oxidations-reduktionspotential', 'oxidations reduktionspotential'],
'ph' => ['ph', 'ph-wert', 'ph wert'],
'chlor' => ['chlor', 'freies chlor', 'gesamtchlor', 'chlorine'],
];
private const NO_LLM_ACCESSORY_PRODUCT_ROLE_KEYWORDS = [
'indikator',
'indicator',
@@ -228,6 +278,57 @@ final class AgentRunnerConfig
return $this->getInt('no_llm_fallback.max_shop_results', 5);
}
/**
* @return string[]
*/
public function getRagEvidenceStopTerms(): array
{
return $this->getStringList('rag_evidence_guard.stop_terms', self::RAG_EVIDENCE_STOP_TERMS);
}
/**
* @return array<string, string[]>
*/
public function getRagEvidenceSynonyms(): array
{
$value = $this->value('rag_evidence_guard.synonyms', self::RAG_EVIDENCE_SYNONYMS);
if (!is_array($value)) {
return self::RAG_EVIDENCE_SYNONYMS;
}
$out = [];
foreach ($value as $key => $items) {
if (!is_scalar($key) || !is_array($items)) {
continue;
}
$key = trim((string) $key);
if ($key === '') {
continue;
}
$terms = [];
foreach ($items as $item) {
if (!is_scalar($item)) {
continue;
}
$item = trim((string) $item);
if ($item !== '' && !in_array($item, $terms, true)) {
$terms[] = $item;
}
}
if ($terms !== []) {
$out[$key] = $terms;
}
}
return $out !== [] ? $out : self::RAG_EVIDENCE_SYNONYMS;
}
public function getNoLlmFallbackShopOnlyMessage(): string
{
return $this->getString(