harden history find tokens and shops earch

This commit is contained in:
team 1
2026-04-26 19:59:31 +02:00
parent ff273ff9a0
commit 308e980b4a
11 changed files with 1230 additions and 75 deletions

View File

@@ -121,84 +121,108 @@ final readonly class AgentRunner
$commerceHistoryContext
);
$shopSearchQuery = $optimizedShopQuery !== '' ? $optimizedShopQuery : $prompt;
$shopQueryPreview = $this->shopSearchService->buildSearchQueryPreview(
$shopSearchQuery,
$commerceIntent,
$commerceHistoryContext
$shopSearchQuery = $this->resolveShopSearchQuery(
prompt: $prompt,
optimizedShopQuery: $optimizedShopQuery,
commerceHistoryContext: $commerceHistoryContext,
userId: $userId
);
yield $this->systemMsg(
$this->buildShopSearchMetaMessage(
query: $shopQueryPreview->searchText !== '' ? $shopQueryPreview->searchText : $shopSearchQuery,
commerceIntent: $commerceIntent,
usedOptimizedQuery: $optimizedShopQuery !== '',
originalQuery: $shopSearchQuery
),
'meta'
);
$this->agentLogger->info('Commerce search prepared', [
'userId' => $userId,
'commerceIntent' => $commerceIntent,
'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
'optimizedShopQuery' => $optimizedShopQuery,
'shopSearchQuery' => $shopSearchQuery,
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
]);
yield $this->systemMsg(
sprintf($this->agentRunnerConfig->getFetchSearchDataMessageTemplate(), $commerceIntent),
'think'
);
$primaryShopResults = $this->searchShop(
$shopSearchQuery,
$commerceIntent,
$userId,
$commerceHistoryContext
);
$primaryShopSearchHadSystemFailure = $this->shopSearchService->hadLastSearchSystemFailure();
$primaryShopSearchFailureReason = $this->shopSearchService->getLastSearchFailureReason();
if ($primaryShopSearchHadSystemFailure) {
$this->agentLogger->warning('Shop repair skipped after Store API system failure', [
if ($shopSearchQuery === '') {
$this->agentLogger->info('Commerce search skipped because no concrete shop query could be resolved', [
'userId' => $userId,
'commerceIntent' => $commerceIntent,
'shopSearchQuery' => $shopSearchQuery,
'failureReason' => $primaryShopSearchFailureReason,
'prompt' => $prompt,
'optimizedShopQuery' => $optimizedShopQuery,
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
]);
$shopUnavailableMessage = $this->buildShopUnavailableMessage($primaryShopSearchFailureReason);
yield $this->systemMsg(
$shopUnavailableMessage,
'err'
);
$historyNotices[] = $this->buildHistoryNotice(
'Shopdaten konnten nicht geladen werden',
$primaryShopSearchFailureReason
$this->agentRunnerConfig->getNoConcreteShopQueryMessage(),
'info'
);
$repairPayload = [
'results' => $primaryShopResults,
'attemptedRepair' => false,
'usedRepair' => false,
'repairQueries' => [],
];
return;
} else {
yield $this->systemMsg('Erweiterte Shopsuche wird geprüft…', 'think');
$repairPayload = $this->repairShopResults(
prompt: $prompt,
userId: $userId,
commerceIntent: $commerceIntent,
commerceHistoryContext: $commerceHistoryContext,
primaryQuery: $shopSearchQuery,
primaryShopResults: $primaryShopResults,
knowledgeChunks: $knowledgeChunks
$shopQueryPreview = $this->shopSearchService->buildSearchQueryPreview(
$shopSearchQuery,
$commerceIntent,
$commerceHistoryContext
);
yield $this->systemMsg(
$this->buildShopSearchMetaMessage(
query: $shopQueryPreview->searchText !== '' ? $shopQueryPreview->searchText : $shopSearchQuery,
commerceIntent: $commerceIntent,
usedOptimizedQuery: $optimizedShopQuery !== '',
originalQuery: $shopSearchQuery
),
'meta'
);
$this->agentLogger->info('Commerce search prepared', [
'userId' => $userId,
'commerceIntent' => $commerceIntent,
'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
'optimizedShopQuery' => $optimizedShopQuery,
'shopSearchQuery' => $shopSearchQuery,
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
]);
yield $this->systemMsg(
sprintf($this->agentRunnerConfig->getFetchSearchDataMessageTemplate(), $commerceIntent),
'think'
);
$primaryShopResults = $this->searchShop(
$shopSearchQuery,
$commerceIntent,
$userId,
$commerceHistoryContext
);
$primaryShopSearchHadSystemFailure = $this->shopSearchService->hadLastSearchSystemFailure();
$primaryShopSearchFailureReason = $this->shopSearchService->getLastSearchFailureReason();
if ($primaryShopSearchHadSystemFailure) {
$this->agentLogger->warning('Shop repair skipped after Store API system failure', [
'userId' => $userId,
'commerceIntent' => $commerceIntent,
'shopSearchQuery' => $shopSearchQuery,
'failureReason' => $primaryShopSearchFailureReason,
]);
$shopUnavailableMessage = $this->buildShopUnavailableMessage($primaryShopSearchFailureReason);
yield $this->systemMsg(
$shopUnavailableMessage,
'err'
);
$historyNotices[] = $this->buildHistoryNotice(
'Shopdaten konnten nicht geladen werden',
$primaryShopSearchFailureReason
);
$repairPayload = [
'results' => $primaryShopResults,
'attemptedRepair' => false,
'usedRepair' => false,
'repairQueries' => [],
];
} else {
yield $this->systemMsg('Erweiterte Shopsuche wird geprüft…', 'think');
$repairPayload = $this->repairShopResults(
prompt: $prompt,
userId: $userId,
commerceIntent: $commerceIntent,
commerceHistoryContext: $commerceHistoryContext,
primaryQuery: $shopSearchQuery,
primaryShopResults: $primaryShopResults,
knowledgeChunks: $knowledgeChunks
);
}
}
$shopResults = $repairPayload['results'];
@@ -645,7 +669,7 @@ final readonly class AgentRunner
return '';
}
return $this->sanitizeOptimizedShopQuery($optimizedQuery);
return $this->sanitizeOptimizedShopQuery($optimizedQuery, $prompt, $commerceHistoryContext);
}
/**
@@ -692,6 +716,189 @@ final readonly class AgentRunner
}
}
private function resolveShopSearchQuery(
string $prompt,
string $optimizedShopQuery,
string $commerceHistoryContext,
string $userId
): string {
if ($optimizedShopQuery !== '' && !$this->isMetaOnlyShopQuery($optimizedShopQuery)) {
return $optimizedShopQuery;
}
if (!$this->isMetaOnlyShopQuery($prompt)) {
return $prompt;
}
$contextQuery = $this->extractContextualShopSearchQuery($commerceHistoryContext);
if ($contextQuery !== '' && !$this->isMetaOnlyShopQuery($contextQuery)) {
return $contextQuery;
}
$extendedHistoryBudget = $this->agentRunnerConfig->getShopQueryContextFallbackHistoryBudgetChars();
if ($extendedHistoryBudget > mb_strlen($commerceHistoryContext, 'UTF-8')) {
$extendedHistory = $this->contextService->buildUserContextWithinBudget($userId, $extendedHistoryBudget);
$extendedContextQuery = $this->extractContextualShopSearchQuery($extendedHistory);
if ($extendedContextQuery !== '' && !$this->isMetaOnlyShopQuery($extendedContextQuery)) {
return $extendedContextQuery;
}
}
if ($this->agentRunnerConfig->shouldUseFullHistoryForShopQueryContextFallback()) {
$fullHistory = $this->contextService->buildUserContext($userId, true);
$fullHistoryContextQuery = $this->extractContextualShopSearchQuery($fullHistory);
if ($fullHistoryContextQuery !== '' && !$this->isMetaOnlyShopQuery($fullHistoryContextQuery)) {
return $fullHistoryContextQuery;
}
}
return '';
}
private function extractContextualShopSearchQuery(string $commerceHistoryContext): string
{
if (!$this->agentRunnerConfig->isShopQueryContextFallbackEnabled()) {
return '';
}
$questions = $this->extractRecentUserQuestions(
$commerceHistoryContext,
$this->agentRunnerConfig->getShopQueryContextFallbackQuestionLimit()
);
for ($i = count($questions) - 1; $i >= 0; $i--) {
$question = trim($questions[$i]);
if ($question === '' || $this->isMetaOnlyShopQuery($question)) {
continue;
}
$contextQuery = $this->buildContextFallbackShopQuery($question);
if ($contextQuery !== '' && !$this->isMetaOnlyShopQuery($contextQuery)) {
return $contextQuery;
}
}
return '';
}
private function buildContextFallbackShopQuery(string $question): string
{
$tokens = $this->tokenizeShopQueryCandidate($question);
if ($tokens === []) {
return '';
}
$filterTerms = [];
foreach (array_merge(
$this->agentRunnerConfig->getShopQueryMetaOnlyTerms(),
$this->agentRunnerConfig->getShopQueryContextFallbackFilterTerms()
) as $term) {
foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
$filterTerms[$token] = true;
}
}
$maxTerms = max(1, $this->agentRunnerConfig->getShopQueryContextFallbackMaxTerms());
$out = [];
foreach ($tokens as $token) {
if (isset($filterTerms[$token])) {
continue;
}
if (in_array($token, $out, true)) {
continue;
}
$out[] = $token;
if (count($out) >= $maxTerms) {
break;
}
}
return implode(' ', $out);
}
/**
* @return string[]
*/
private function tokenizeShopQueryCandidate(string $value): array
{
$value = mb_strtolower(trim($value), 'UTF-8');
$value = str_replace(['-', '/', '_'], ' ', $value);
if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', $value, $matches) !== 1) {
return [];
}
return array_values(array_filter(
array_map(static fn(string $token): string => trim($token), $matches[0] ?? []),
static fn(string $token): bool => $token !== ''
));
}
private function isMetaOnlyShopQuery(string $query): bool
{
if (!$this->agentRunnerConfig->isShopQueryMetaGuardEnabled()) {
return false;
}
$tokens = $this->tokenizeMetaGuardText($query);
if ($tokens === []) {
return true;
}
$metaTerms = [];
foreach ($this->agentRunnerConfig->getShopQueryMetaOnlyTerms() as $term) {
foreach ($this->tokenizeMetaGuardText($term) as $token) {
$metaTerms[$token] = true;
}
}
if ($metaTerms === []) {
return false;
}
foreach ($tokens as $token) {
if (!isset($metaTerms[$token])) {
return false;
}
}
return true;
}
/**
* @return string[]
*/
private function tokenizeMetaGuardText(string $value): array
{
$value = mb_strtolower(trim($value), 'UTF-8');
$value = str_replace(['-', '/', '_'], ' ', $value);
$value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
$value = trim($value);
if ($value === '') {
return [];
}
return array_values(array_filter(
explode(' ', $value),
static fn(string $token): bool => $token !== ''
));
}
private function searchShop(
string $query,
string $commerceIntent,
@@ -743,8 +950,11 @@ final readonly class AgentRunner
};
}
private function sanitizeOptimizedShopQuery(string $query): string
{
private function sanitizeOptimizedShopQuery(
string $query,
string $sourcePrompt = '',
string $commerceHistoryContext = ''
): string {
$query = trim($query);
if ($query === '') {
@@ -755,10 +965,162 @@ final readonly class AgentRunner
$query = preg_replace($this->agentRunnerConfig->getOptimizedShopQueryPrefixPattern(), '', $query) ?? $query;
$query = trim($query, $this->agentRunnerConfig->getOptimizedShopQueryTrimCharacters());
$query = preg_replace('/\s+/u', ' ', $query) ?? $query;
$query = $this->preserveOptimizedShopQueryLanguage($query, $sourcePrompt);
$query = $this->enrichReferentialShopQueryFromHistory($query, $sourcePrompt, $commerceHistoryContext);
$query = preg_replace('/\s+/u', ' ', $query) ?? $query;
return trim($query);
}
private function enrichReferentialShopQueryFromHistory(
string $query,
string $sourcePrompt,
string $commerceHistoryContext
): string {
if (!$this->agentRunnerConfig->isShopQueryContextAnchorEnrichmentEnabled()) {
return $query;
}
if (trim($commerceHistoryContext) === '') {
return $query;
}
$queryTokens = $this->tokenizeShopQueryCandidate($query);
if ($queryTokens === []) {
return $query;
}
$maxTerms = max(1, $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentMaxQueryTerms());
if (count($queryTokens) > $maxTerms) {
return $query;
}
if (!$this->containsConfiguredShopQueryAnchorTrigger(trim($query . ' ' . $sourcePrompt))) {
return $query;
}
$anchor = $this->normalizeShopQueryAnchor(
$this->extractLatestConfiguredShopQueryContextAnchor($commerceHistoryContext)
);
if ($anchor === '' || $this->queryAlreadyContainsAllAnchorTokens($query, $anchor)) {
return $query;
}
$template = $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTemplate();
$enriched = str_replace(['{anchor}', '{query}'], [$anchor, $query], $template);
$enriched = preg_replace('/\s+/u', ' ', $enriched) ?? $enriched;
return trim($enriched) !== '' ? trim($enriched) : $query;
}
private function containsConfiguredShopQueryAnchorTrigger(string $text): bool
{
$tokens = $this->tokenizeShopQueryCandidate($text);
if ($tokens === []) {
return false;
}
$tokenSet = array_fill_keys($tokens, true);
foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTriggerTerms() as $term) {
foreach ($this->tokenizeShopQueryCandidate($term) as $termToken) {
if (isset($tokenSet[$termToken])) {
return true;
}
}
}
return false;
}
private function extractLatestConfiguredShopQueryContextAnchor(string $commerceHistoryContext): string
{
$latest = '';
foreach ($this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentPatterns() as $pattern) {
if (@preg_match_all($pattern, $commerceHistoryContext, $matches, PREG_SET_ORDER) === false) {
continue;
}
foreach ($matches as $match) {
$candidate = trim((string) ($match[0] ?? ''));
if ($candidate !== '') {
$latest = $candidate;
}
}
}
return $latest;
}
private function normalizeShopQueryAnchor(string $anchor): string
{
$anchor = str_replace('®', '', $anchor);
$anchor = mb_strtolower(trim($anchor), 'UTF-8');
$anchor = preg_replace('/[^\p{L}\p{N},.%°+\-\s]+/u', ' ', $anchor) ?? $anchor;
$anchor = preg_replace('/\s+/u', ' ', $anchor) ?? $anchor;
return trim($anchor);
}
private function queryAlreadyContainsAllAnchorTokens(string $query, string $anchor): bool
{
$queryTokens = array_fill_keys($this->tokenizeShopQueryCandidate($query), true);
foreach ($this->tokenizeShopQueryCandidate($anchor) as $token) {
if (!isset($queryTokens[$token])) {
return false;
}
}
return true;
}
private function preserveOptimizedShopQueryLanguage(string $query, string $sourcePrompt): string
{
if (!$this->agentRunnerConfig->isShopQueryLanguagePreservationEnabled()) {
return $query;
}
$language = $this->detectConfiguredShopQueryLanguage($sourcePrompt);
if ($language === null) {
return $query;
}
$replacements = $this->agentRunnerConfig->getShopQueryTranslationReplacements($language);
if ($replacements === []) {
return $query;
}
foreach ($replacements as $source => $target) {
$pattern = '/(?<![\\p{L}\\p{N}])' . preg_replace('/\\s+/u', '\\s+', preg_quote($source, '/')) . '(?![\\p{L}\\p{N}])/iu';
$query = preg_replace($pattern, $target, $query) ?? $query;
}
return $query;
}
private function detectConfiguredShopQueryLanguage(string $sourcePrompt): ?string
{
$normalized = ' ' . strtolower($sourcePrompt) . ' ';
$normalized = preg_replace('/[\\r\\n\\t]+/u', ' ', $normalized) ?? $normalized;
$normalized = preg_replace('/\\s+/u', ' ', $normalized) ?? $normalized;
foreach ($this->agentRunnerConfig->getShopQueryLanguageMarkers() as $language => $markers) {
foreach ($markers as $marker) {
if ($marker !== '' && str_contains($normalized, $marker)) {
return $language;
}
}
}
return null;
}
/**
* @return Generator<int, string, mixed, string>
*/
@@ -993,4 +1355,4 @@ final readonly class AgentRunner
default => $msg,
};
}
}
}

View File

@@ -46,6 +46,29 @@ final class AgentRunnerConfig
return is_numeric($value) ? (int) $value : $default;
}
private function getBool(string $key, bool $default): bool
{
$value = $this->value($key, $default);
if (is_bool($value)) {
return $value;
}
if (is_scalar($value)) {
$normalized = strtolower(trim((string) $value));
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
return false;
}
}
return $default;
}
private function getString(string $key, string $default): string
{
$value = $this->value($key, $default);
@@ -122,6 +145,14 @@ final class AgentRunnerConfig
return $this->getString('messages.optimize_search', 'Ich optimiere die Recherche...');
}
public function getNoConcreteShopQueryMessage(): string
{
return $this->getString(
'messages.no_concrete_shop_query',
'Ich habe keine konkrete Shop-Suchanfrage erkannt. Bitte nenne das Produkt, Zubehör oder die Artikelnummer.'
);
}
public function getFetchSearchDataMessageTemplate(): string
{
return $this->getString('messages.fetch_search_data_template', 'Ich rufe Recherchedaten ab (type: %s)');
@@ -252,11 +283,14 @@ final class AgentRunnerConfig
'- Maximum 6 search terms, preferably fewer.',
'- Remove filler words, polite phrases, and irrelevant words.',
'- Preserve product names, brands, model numbers, and compound terms exactly if they are relevant.',
'- Preserve the language of the CURRENT USER INPUT for generic product/search terms; do not translate German search terms into English.',
'- For German user input, output German shop terms, for example "freies Chlor Messung" instead of "free chlorine measurement".',
'- Preserve domain terms from the current user input or resolved context in their original language.',
'- Numbers that belong to a product name or model must be preserved (e.g. Indikator 300, Testomat 808, Testomat 2000).',
'- Separate terms using spaces only.',
'- If a relevant product name is present, it must be placed at the beginning of the final search query.',
'- Try to always identify all products mentioned in the user input text, even in long prompts.',
'- Look for terms such as Testomat, Horiba, Tritromat, or words like indicator.',
'- Look for terms such as Testomat, Horiba, Tritromat, or words like indicator/Indikator.',
'- If the current user input is vague or referential, use the recent conversation context only as support.',
'- Do not output words that only describe conversation flow, such as "same", "again", "also", or "like above".',
]);
@@ -297,6 +331,253 @@ final class AgentRunnerConfig
return $this->getString('shop_prompt.current_user_input_label', 'CURRENT USER INPUT');
}
public function isShopQueryLanguagePreservationEnabled(): bool
{
return $this->getBool('shop_prompt.language_preservation.enabled', true);
}
/**
* @return array<string, string[]>
*/
public function getShopQueryLanguageMarkers(): array
{
$default = [
'de' => [
' ä ', ' ö ', ' ü ', ' ß ',
' der ', ' die ', ' das ', ' ein ', ' eine ', ' einer ', ' einen ',
' welchem ', ' welchen ', ' welche ', ' welcher ',
' kann ', ' nutzen ', ' zur ', ' für ', ' fuer ',
' messung ', ' indikator ', ' reagenz ', ' chlor ',
],
];
$value = $this->value('shop_prompt.language_preservation.language_markers', $default);
if (!is_array($value)) {
return $default;
}
$out = [];
foreach ($value as $language => $markers) {
if (!is_string($language) || !is_array($markers)) {
continue;
}
$cleanMarkers = [];
foreach ($markers as $marker) {
if (!is_scalar($marker)) {
continue;
}
$marker = strtolower((string) $marker);
if ($marker !== '') {
$cleanMarkers[] = $marker;
}
}
if ($cleanMarkers !== []) {
$out[$language] = array_values(array_unique($cleanMarkers));
}
}
return $out !== [] ? $out : $default;
}
/**
* @return array<string, string>
*/
public function isShopQueryMetaGuardEnabled(): bool
{
return $this->getBool('shop_prompt.meta_query_guard.enabled', true);
}
/**
* @return string[]
*/
public function getShopQueryMetaOnlyTerms(): array
{
return $this->getStringList('shop_prompt.meta_query_guard.meta_only_terms', [
'shop',
'shopsuche',
'shop-suche',
'suche',
'suchen',
'such',
'finde',
'find',
'zeige',
'zeig',
'bitte',
'mal',
'im',
'in',
'nach',
'den',
'die',
'das',
'der',
'dem',
]);
}
public function isShopQueryContextFallbackEnabled(): bool
{
return $this->getBool('shop_prompt.meta_query_guard.context_fallback_enabled', true);
}
public function getShopQueryContextFallbackQuestionLimit(): int
{
return $this->getInt('shop_prompt.meta_query_guard.context_fallback_question_limit', 12);
}
public function getShopQueryContextFallbackHistoryBudgetChars(): int
{
return $this->getInt('shop_prompt.meta_query_guard.context_fallback_history_budget_chars', 20000);
}
public function shouldUseFullHistoryForShopQueryContextFallback(): bool
{
return $this->getBool('shop_prompt.meta_query_guard.context_fallback_use_full_history', true);
}
public function getShopQueryContextFallbackMaxTerms(): int
{
return $this->getInt('shop_prompt.meta_query_guard.context_fallback_max_terms', 6);
}
/**
* @return string[]
*/
public function getShopQueryContextFallbackFilterTerms(): array
{
return $this->getStringList('shop_prompt.meta_query_guard.context_fallback_filter_terms', [
'mit',
'welche',
'welcher',
'welches',
'welchem',
'welchen',
'was',
'wie',
'wo',
'kann',
'koennen',
'können',
'konnte',
'könnte',
'ich',
'wir',
'man',
'nutzen',
'benutzen',
'verwenden',
'verwende',
'nehmen',
'zur',
'zum',
'für',
'fuer',
'messen',
'gemessen',
'messung',
]);
}
public function isShopQueryContextAnchorEnrichmentEnabled(): bool
{
return $this->getBool('shop_prompt.context_anchor_enrichment.enabled', true);
}
public function getShopQueryContextAnchorEnrichmentMaxQueryTerms(): int
{
return $this->getInt('shop_prompt.context_anchor_enrichment.max_query_terms', 2);
}
/**
* @return string[]
*/
public function getShopQueryContextAnchorEnrichmentTriggerTerms(): array
{
return $this->getStringList('shop_prompt.context_anchor_enrichment.trigger_terms', [
'indikator',
'indikatortyp',
'indicator',
'reagenz',
'reagenzsatz',
'reagent',
'zubehör',
'zubehor',
'accessory',
]);
}
/**
* @return string[]
*/
public function getShopQueryContextAnchorEnrichmentPatterns(): array
{
return $this->getStringList('shop_prompt.context_anchor_enrichment.anchor_patterns', [
'/\b(?:indikator(?:typ)?|indicator(?:\s+type)?|reagenz(?:satz|typ)?|reagent(?:\s+set|\s+type)?|typ|type)\s+[A-Za-zÄÖÜäöüß]{0,8}\s*\d{1,5}(?:\s*[A-ZÄÖÜ]{1,4})?(?:\s*%)?\b/iu',
]);
}
public function getShopQueryContextAnchorEnrichmentTemplate(): string
{
return $this->getString('shop_prompt.context_anchor_enrichment.template', '{anchor} {query}');
}
public function getShopQueryTranslationReplacements(string $language): array
{
$default = [
'de' => [
'free chlorine' => 'freies chlor',
'free chlor' => 'freies chlor',
'total chlorine' => 'gesamtchlor',
'chlorine measurement' => 'chlor messung',
'water hardness' => 'wasserhärte',
'measurement' => 'messung',
'measuring' => 'messung',
'chlorine' => 'chlor',
'indicator' => 'indikator',
'indicators' => 'indikatoren',
'reagent' => 'reagenz',
'reagents' => 'reagenzien',
'accessory' => 'zubehör',
'accessories' => 'zubehör',
],
];
$value = $this->value(
'shop_prompt.language_preservation.translation_replacements.' . $language,
$default[$language] ?? []
);
if (!is_array($value)) {
return $default[$language] ?? [];
}
$out = [];
foreach ($value as $source => $target) {
if (!is_scalar($source) || !is_scalar($target)) {
continue;
}
$source = strtolower(trim((string) $source));
$target = trim((string) $target);
if ($source !== '' && $target !== '') {
$out[$source] = $target;
}
}
uksort($out, static fn(string $a, string $b): int => strlen($b) <=> strlen($a));
return $out !== [] ? $out : ($default[$language] ?? []);
}
private function buildRulesBlock(array $rules, string $headline = 'Rules:'): string
{
return $headline . "\n" . implode("\n", $rules);
@@ -317,4 +598,4 @@ final class AgentRunnerConfig
return implode("\n\n", $normalized);
}
}
}

View File

@@ -192,6 +192,47 @@ final readonly class RetriexEffectiveConfigProvider
$errors[] = 'Shop query optimizer prompt no longer contains the original query.';
}
$metaOnlyTerms = $this->agentRunnerConfig->getShopQueryMetaOnlyTerms();
foreach (['shop', 'suche'] as $term) {
$key = 'shop_query_meta_guard_term_' . $term;
$checks[$key] = in_array($term, $metaOnlyTerms, true);
if (!$checks[$key]) {
$errors[] = 'Missing shop query meta guard term: ' . $term;
}
}
$checks['shop_query_context_fallback_enabled'] = $this->agentRunnerConfig->isShopQueryContextFallbackEnabled();
if (!$checks['shop_query_context_fallback_enabled']) {
$errors[] = 'Shop query context fallback is disabled.';
}
$contextFallbackFilterTerms = $this->agentRunnerConfig->getShopQueryContextFallbackFilterTerms();
foreach (['welchem', 'kann', 'messen'] as $term) {
$key = 'shop_query_context_fallback_filter_' . $term;
$checks[$key] = in_array($term, $contextFallbackFilterTerms, true);
if (!$checks[$key]) {
$errors[] = 'Missing shop query context fallback filter term: ' . $term;
}
}
$checks['shop_query_context_fallback_history_budget_positive'] = $this->agentRunnerConfig->getShopQueryContextFallbackHistoryBudgetChars() > 0;
if (!$checks['shop_query_context_fallback_history_budget_positive']) {
$errors[] = 'Shop query context fallback history budget must be greater than zero.';
}
$checks['shop_query_context_fallback_full_history_enabled'] = $this->agentRunnerConfig->shouldUseFullHistoryForShopQueryContextFallback();
if (!$checks['shop_query_context_fallback_full_history_enabled']) {
$errors[] = 'Shop query context fallback full-history fallback is disabled.';
}
$checks['shop_query_context_fallback_question_limit_minimum'] = $this->agentRunnerConfig->getShopQueryContextFallbackQuestionLimit() >= 6;
if (!$checks['shop_query_context_fallback_question_limit_minimum']) {
$errors[] = 'Shop query context fallback question limit is too low for repeated meta follow-ups.';
}
$checks['shop_query_context_fallback_max_terms_positive'] = $this->agentRunnerConfig->getShopQueryContextFallbackMaxTerms() > 0;
if (!$checks['shop_query_context_fallback_max_terms_positive']) {
$errors[] = 'Shop query context fallback max terms must be greater than zero.';
}
$status = $errors === [] ? 'OK' : 'ERROR';
return [
@@ -362,6 +403,7 @@ final readonly class RetriexEffectiveConfigProvider
'check_internet_sources' => $this->agentRunnerConfig->getCheckInternetSourcesMessage(),
'retrieve_knowledge' => $this->agentRunnerConfig->getRetrieveKnowledgeMessage(),
'optimize_search' => $this->agentRunnerConfig->getOptimizeSearchMessage(),
'no_concrete_shop_query' => $this->agentRunnerConfig->getNoConcreteShopQueryMessage(),
'fetch_search_data_template' => $this->agentRunnerConfig->getFetchSearchDataMessageTemplate(),
'analyze_all_information' => $this->agentRunnerConfig->getAnalyzeAllInformationMessage(),
'thinking_while_streaming' => $this->agentRunnerConfig->getThinkingWhileStreamingMessage(),
@@ -392,6 +434,28 @@ final readonly class RetriexEffectiveConfigProvider
'output_format_block' => $this->agentRunnerConfig->getShopPromptOutputFormatBlock(),
'recent_conversation_context_label' => $this->agentRunnerConfig->getRecentConversationContextLabel(),
'current_user_input_label' => $this->agentRunnerConfig->getCurrentUserInputLabel(),
'language_preservation' => [
'enabled' => $this->agentRunnerConfig->isShopQueryLanguagePreservationEnabled(),
'language_markers' => $this->agentRunnerConfig->getShopQueryLanguageMarkers(),
'translation_replacements_de' => $this->agentRunnerConfig->getShopQueryTranslationReplacements('de'),
],
'context_anchor_enrichment' => [
'enabled' => $this->agentRunnerConfig->isShopQueryContextAnchorEnrichmentEnabled(),
'max_query_terms' => $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentMaxQueryTerms(),
'trigger_terms' => $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTriggerTerms(),
'anchor_patterns' => $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentPatterns(),
'template' => $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTemplate(),
],
'meta_query_guard' => [
'enabled' => $this->agentRunnerConfig->isShopQueryMetaGuardEnabled(),
'context_fallback_use_full_history' => $this->agentRunnerConfig->shouldUseFullHistoryForShopQueryContextFallback(),
'meta_only_terms' => $this->agentRunnerConfig->getShopQueryMetaOnlyTerms(),
'context_fallback_enabled' => $this->agentRunnerConfig->isShopQueryContextFallbackEnabled(),
'context_fallback_question_limit' => $this->agentRunnerConfig->getShopQueryContextFallbackQuestionLimit(),
'context_fallback_history_budget_chars' => $this->agentRunnerConfig->getShopQueryContextFallbackHistoryBudgetChars(),
'context_fallback_max_terms' => $this->agentRunnerConfig->getShopQueryContextFallbackMaxTerms(),
'context_fallback_filter_terms' => $this->agentRunnerConfig->getShopQueryContextFallbackFilterTerms(),
],
],
];
}
@@ -795,6 +859,15 @@ final readonly class RetriexEffectiveConfigProvider
$this->validateStringListMap($agent['html_templates'] ?? [], 'agent.html_templates', $errors, $warnings);
$this->validateStringListMap($agent['shop_query_optimizer'] ?? [], 'agent.shop_query_optimizer', $errors, $warnings);
$this->validateRegexPattern($agent['optimized_shop_query_prefix_pattern'] ?? null, 'agent.optimized_shop_query_prefix_pattern', $errors);
$anchorEnrichment = $agent['shop_query_optimizer']['context_anchor_enrichment'] ?? [];
if (is_array($anchorEnrichment)) {
$this->validateStringList($this->toList($anchorEnrichment['trigger_terms'] ?? []), 'agent.shop_query_optimizer.context_anchor_enrichment.trigger_terms', $errors, $warnings);
$this->validateRegexPatternList($anchorEnrichment['anchor_patterns'] ?? [], 'agent.shop_query_optimizer.context_anchor_enrichment.anchor_patterns', $errors);
if (trim((string) ($anchorEnrichment['template'] ?? '')) === '') {
$errors[] = 'agent.shop_query_optimizer.context_anchor_enrichment.template must not be empty.';
}
}
}
/**

View File

@@ -92,6 +92,13 @@ final readonly class AskSseController
if (($claimed['ok'] ?? false) !== true) {
$this->prepareStreamRuntime();
echo "retry: 15000\n\n";
if ($this->shouldSilentlyCloseDuplicateJobStream($claimed)) {
$this->sendComment('duplicate-or-finished-stream');
$this->sendEvent('done', '[DONE]');
return;
}
$this->sendEvent('error', $this->jobClaimErrorMessage($claimed));
$this->sendEvent('done', '[DONE]');
return;
@@ -504,6 +511,24 @@ final readonly class AskSseController
}
}
}
/**
* EventSource may reconnect to an already running or already completed job.
* Those duplicate connections should be closed quietly so the UI does not
* append a misleading error after the real stream already produced output.
*
* @param array<string, mixed> $claim
*/
private function shouldSilentlyCloseDuplicateJobStream(array $claim): bool
{
if (($claim['reason'] ?? null) !== 'not_pending') {
return false;
}
$status = (string) ($claim['status'] ?? '');
return $status === self::JOB_STATUS_RUNNING
|| $status === self::JOB_STATUS_COMPLETED;
}
/**
* @param array<string, mixed> $claim
@@ -548,7 +573,6 @@ final readonly class AskSseController
return 'Der Antwort-Job wurde nicht gefunden. Falls deine Verbindung kurz unterbrochen war, sende die Anfrage bitte erneut.';
}
private function cleanupExpiredJobs(): void
{
$directory = $this->jobDirectory();