This commit is contained in:
team 1
2026-05-06 10:53:54 +02:00
parent 68bfbd7802
commit 4832c2e287
4 changed files with 462 additions and 3 deletions

View File

@@ -212,6 +212,13 @@ parameters:
vocabulary_maps:
synonyms: agent.rag_evidence_guard.synonyms
direct_shop_result_answer:
enabled: true
max_results: 10
intro: 'Aus den Shopdaten ergeben sich folgende passende Treffer:'
no_results: 'Ich finde in den Shopdaten keine passenden Treffer für die angefragte Produktsuche. Ich liste deshalb keine fachfremden Ersatzprodukte auf.'
sorted_by_length_note: 'Sortierung: aufsteigend nach erkannter Kabellänge.'
no_llm_fallback:
max_shop_results: 5
messages:
@@ -440,6 +447,66 @@ parameters:
comparative_constraint_patterns:
- '/\b(?:länger|laenger|kürzer|kuerzer|größer|groesser|kleiner|über|ueber|unter|mindestens|maximal|maximum|minimum|ab|bis|mehr\s+als|weniger\s+als)\s+(?P<value>\d+(?:[,.]\d+)?\s*[\p{L}µ°%]*)\b/iu'
query_stopword_cleanup:
enabled: true
min_query_tokens_after_cleanup: 2
# Plain Shopware text search should contain product-relevant terms only.
# These terms are UI, instruction, presentation or sorting words and are
# removed after LLM query optimization. Keep this list simple and local.
terms:
- zeige
- zeig
- suche
- such
- finde
- find
- gib
- gebe
- nenne
- mir
- bitte
- ich
- wir
- im
- in
- shop
- für
- fuer
- nach
- mit
- ohne
- von
- zum
- zur
- der
- die
- das
- ein
- eine
- einen
- ordne
- sortiere
- sortiert
- sortierung
- liste
- tabelle
- übersicht
- uebersicht
- auflistung
- meter
- metern
direct_result_guard:
enabled: true
length_sort:
enabled: true
trigger_patterns:
- '/\b(?:ordne|sortiere|sortiert|sortierung)\b.{0,80}\b(?:meter|metern|m)\b/iu'
- '/\bnach\s+(?:meter|metern|m)\b/iu'
value_patterns:
- '/(?P<value>\d+(?:[,.]\d+)?)\s*(?:m|meter|metern)\b/iu'
context_usage:
referential_terms:
- der

View File

@@ -35,7 +35,9 @@ a {
color: #7a9ed1;
text-decoration: none;
}
li {
margin-bottom: .5rem;
}
a:hover {
color: #FFF;
}

View File

@@ -467,6 +467,8 @@ final readonly class AgentRunner
}
$shopResults = $repairPayload['results'];
$shopResults = $this->guardDirectProductShopResults($prompt, $shopSearchQuery, $shopResults);
$shopResults = $this->sortShopResultsForLengthRequest($prompt, $shopSearchQuery, $shopResults);
$attemptedShopRepair = $repairPayload['attemptedRepair'];
$usedShopRepair = $repairPayload['usedRepair'];
$shopRepairQueries = $repairPayload['repairQueries'];
@@ -604,7 +606,21 @@ final readonly class AgentRunner
knowledgeEvidenceState: $knowledgeEvidenceState
);
$fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer);
$deterministicDirectShopAnswer = $this->buildDeterministicDirectShopResultAnswer(
prompt: $prompt,
shopResults: $shopResults,
commerceIntent: $commerceIntent,
shopSearchAttempted: $shopSearchAttempted,
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
shopSearchQuery: $shopSearchQuery
);
if ($deterministicDirectShopAnswer !== '') {
$fullOutput = $deterministicDirectShopAnswer;
yield $this->systemMsg($deterministicDirectShopAnswer, 'answer');
} else {
$fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer);
}
yield $this->systemMsg(
$this->buildProductionUiMetaMessage(
@@ -1565,7 +1581,49 @@ final readonly class AgentRunner
? $this->preserveCurrentInputShopQueryTerms($prompt, $guardedQuery)
: $this->preserveCurrentInputShopQueryTerms($prompt, $shopSearchQuery);
return $this->cleanupDirectProductAttributeShopQuery($prompt, $query);
$query = $this->cleanupDirectProductAttributeShopQuery($prompt, $query);
return $this->cleanupShopQueryStopwords($query);
}
private function cleanupShopQueryStopwords(string $shopSearchQuery): string
{
$shopSearchQuery = trim($shopSearchQuery);
if (
$shopSearchQuery === ''
|| !$this->agentRunnerConfig->isShopQueryStopwordCleanupEnabled()
) {
return $shopSearchQuery;
}
$removeTokens = [];
foreach ($this->agentRunnerConfig->getShopQueryStopwordCleanupTerms() as $term) {
foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
$removeTokens[$token] = true;
}
}
if ($removeTokens === []) {
return $shopSearchQuery;
}
$kept = [];
foreach ($this->tokenizeShopQueryCandidate($shopSearchQuery) as $token) {
if (isset($removeTokens[$token]) || isset($kept[$token])) {
continue;
}
$kept[$token] = $token;
}
if (count($kept) < max(1, $this->agentRunnerConfig->getShopQueryStopwordCleanupMinTokens())) {
return $shopSearchQuery;
}
$cleaned = implode(' ', array_values($kept));
return $cleaned !== '' ? $cleaned : $shopSearchQuery;
}
private function cleanupDirectProductAttributeShopQuery(string $prompt, string $shopSearchQuery): string
@@ -2883,6 +2941,269 @@ final readonly class AgentRunner
return mb_strtolower($line, 'UTF-8');
}
/**
* @param ShopProductResult[] $shopResults
* @return ShopProductResult[]
*/
private function guardDirectProductShopResults(string $prompt, string $shopSearchQuery, array $shopResults): array
{
if (
$shopResults === []
|| !$this->agentRunnerConfig->isDirectShopResultGuardEnabled()
) {
return $shopResults;
}
$requestedTerms = $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery);
if ($requestedTerms === []) {
return $shopResults;
}
$filtered = [];
foreach ($shopResults as $product) {
if (!$product instanceof ShopProductResult) {
continue;
}
if ($this->shopProductMatchesAnyDirectProductTerm($product, $requestedTerms)) {
$filtered[] = $product;
}
}
return $filtered;
}
/**
* @param ShopProductResult[] $shopResults
* @return ShopProductResult[]
*/
private function sortShopResultsForLengthRequest(string $prompt, string $shopSearchQuery, array $shopResults): array
{
if (
count($shopResults) < 2
|| !$this->agentRunnerConfig->isShopResultLengthSortEnabled()
|| !$this->isShopResultLengthSortRequested($prompt . ' ' . $shopSearchQuery)
) {
return $shopResults;
}
$hasLength = false;
$decorated = [];
foreach (array_values($shopResults) as $index => $product) {
$length = $product instanceof ShopProductResult
? $this->extractShopProductLengthMeters($product)
: null;
$hasLength = $hasLength || $length !== null;
$decorated[] = [
'index' => $index,
'length' => $length,
'product' => $product,
];
}
if (!$hasLength) {
return $shopResults;
}
usort($decorated, static function (array $a, array $b): int {
if ($a['length'] === null && $b['length'] === null) {
return $a['index'] <=> $b['index'];
}
if ($a['length'] === null) {
return 1;
}
if ($b['length'] === null) {
return -1;
}
$lengthCompare = $a['length'] <=> $b['length'];
return $lengthCompare !== 0 ? $lengthCompare : ($a['index'] <=> $b['index']);
});
return array_values(array_map(
static fn(array $row): mixed => $row['product'],
$decorated
));
}
private function isShopResultLengthSortRequested(string $text): bool
{
foreach ($this->agentRunnerConfig->getShopResultLengthSortTriggerPatterns() as $pattern) {
if (@preg_match($pattern, $text) === 1) {
return true;
}
}
return false;
}
private function extractShopProductLengthMeters(ShopProductResult $product): ?float
{
$text = trim(implode(' ', array_filter([
$product->name,
$product->description,
implode(' ', $product->highlights),
$product->customFields,
])));
if ($text === '') {
return null;
}
foreach ($this->agentRunnerConfig->getShopResultLengthSortValuePatterns() as $pattern) {
if (@preg_match($pattern, $text, $matches) !== 1) {
continue;
}
$value = $matches['value'] ?? ($matches[1] ?? null);
if (!is_scalar($value)) {
continue;
}
$normalized = str_replace(',', '.', (string) $value);
if (is_numeric($normalized)) {
return (float) $normalized;
}
}
return null;
}
/**
* @return string[]
*/
private function extractRequestedDirectProductTerms(string $prompt, string $shopSearchQuery = ''): array
{
$combined = trim($prompt . ' ' . $shopSearchQuery);
if ($combined === '') {
return [];
}
$terms = [];
foreach ($this->agentRunnerConfig->getShopQueryProductAttributeCleanupProductTypeTerms() as $term) {
if ($this->containsAllShopQueryTokens($combined, $term)) {
$terms[] = $term;
}
}
return array_values(array_unique($terms));
}
private function containsAllShopQueryTokens(string $text, string $term): bool
{
$tokens = array_fill_keys($this->tokenizeShopQueryCandidate($text), true);
$termTokens = $this->tokenizeShopQueryCandidate($term);
if ($tokens === [] || $termTokens === []) {
return false;
}
foreach ($termTokens as $termToken) {
if (!isset($tokens[$termToken])) {
return false;
}
}
return true;
}
/**
* @param string[] $requestedTerms
*/
private function shopProductMatchesAnyDirectProductTerm(ShopProductResult $product, array $requestedTerms): bool
{
$productText = trim(implode(' ', array_filter([
$product->name,
$product->description,
implode(' ', $product->highlights),
$product->customFields,
])));
foreach ($requestedTerms as $term) {
if ($this->containsAllShopQueryTokens($productText, $term)) {
return true;
}
}
return false;
}
/**
* @param ShopProductResult[] $shopResults
*/
private function buildDeterministicDirectShopResultAnswer(
string $prompt,
array $shopResults,
string $commerceIntent,
bool $shopSearchAttempted,
bool $shopSearchHadSystemFailure,
string $shopSearchQuery
): string {
if (
!$this->agentRunnerConfig->isDirectShopResultAnswerEnabled()
|| !$this->isCommerceIntent($commerceIntent)
|| !$shopSearchAttempted
|| $shopSearchHadSystemFailure
|| $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery) === []
) {
return '';
}
if ($shopResults === []) {
return $this->agentRunnerConfig->getDirectShopResultAnswerNoResultsMessage();
}
$lines = [$this->agentRunnerConfig->getDirectShopResultAnswerIntro()];
if ($this->isShopResultLengthSortRequested($prompt . ' ' . $shopSearchQuery)) {
$note = trim($this->agentRunnerConfig->getDirectShopResultAnswerSortedByLengthNote());
if ($note !== '') {
$lines[] = $note;
}
}
$lines[] = '';
foreach ($this->buildDirectShopProductLines($shopResults, 'accessory_or_consumable') as $line) {
$lines[] = $line;
}
return trim(implode("\n", $lines));
}
/**
* @param ShopProductResult[] $shopResults
* @return string[]
*/
private function buildDirectShopProductLines(array $shopResults, string $requestedProductRole): array
{
$maxResults = max(1, $this->agentRunnerConfig->getDirectShopResultAnswerMaxResults());
$lines = [];
$index = 1;
foreach ($shopResults as $product) {
if (!$product instanceof ShopProductResult) {
continue;
}
$lines[] = $this->formatNoLlmShopProductLine($product, $index, $requestedProductRole);
$index++;
if (count($lines) >= $maxResults) {
break;
}
}
if ($lines === []) {
return [$this->agentRunnerConfig->getNoLlmProductField('unreadable_results_message')];
}
return $lines;
}
/**
* Build a deterministic safety answer for environments where the LLM returns no tokens.
*

View File

@@ -732,6 +732,31 @@ final class AgentRunnerConfig
return $this->getRequiredStringList('final_answer_guard.repeated_line.ignore_patterns');
}
public function isDirectShopResultAnswerEnabled(): bool
{
return $this->getRequiredBool('direct_shop_result_answer.enabled');
}
public function getDirectShopResultAnswerMaxResults(): int
{
return $this->getRequiredInt('direct_shop_result_answer.max_results');
}
public function getDirectShopResultAnswerIntro(): string
{
return $this->getRequiredString('direct_shop_result_answer.intro');
}
public function getDirectShopResultAnswerNoResultsMessage(): string
{
return $this->getRequiredString('direct_shop_result_answer.no_results');
}
public function getDirectShopResultAnswerSortedByLengthNote(): string
{
return $this->getRequiredString('direct_shop_result_answer.sorted_by_length_note');
}
public function getNoLlmFallbackMaxShopResults(): int
{
return $this->getRequiredInt('no_llm_fallback.max_shop_results');
@@ -1082,6 +1107,50 @@ final class AgentRunnerConfig
return $this->getRequiredStringList('shop_prompt.product_attribute_query_cleanup.comparative_constraint_patterns');
}
public function isShopQueryStopwordCleanupEnabled(): bool
{
return $this->getRequiredBool('shop_prompt.query_stopword_cleanup.enabled');
}
public function getShopQueryStopwordCleanupMinTokens(): int
{
return $this->getRequiredInt('shop_prompt.query_stopword_cleanup.min_query_tokens_after_cleanup');
}
/**
* @return string[]
*/
public function getShopQueryStopwordCleanupTerms(): array
{
return $this->getRequiredStringList('shop_prompt.query_stopword_cleanup.terms');
}
public function isDirectShopResultGuardEnabled(): bool
{
return $this->getRequiredBool('shop_prompt.direct_result_guard.enabled');
}
public function isShopResultLengthSortEnabled(): bool
{
return $this->getRequiredBool('shop_prompt.length_sort.enabled');
}
/**
* @return string[]
*/
public function getShopResultLengthSortTriggerPatterns(): array
{
return $this->getRequiredStringList('shop_prompt.length_sort.trigger_patterns');
}
/**
* @return string[]
*/
public function getShopResultLengthSortValuePatterns(): array
{
return $this->getRequiredStringList('shop_prompt.length_sort.value_patterns');
}
public function getShopPromptIntro(): string
{
return $this->getRequiredString('shop_prompt.intro');