602 lines
19 KiB
PHP
602 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Config;
|
|
|
|
final class AgentRunnerConfig
|
|
{
|
|
/**
|
|
* @param array<string, mixed> $config
|
|
*/
|
|
public function __construct(
|
|
private readonly array $config = [],
|
|
) {
|
|
}
|
|
|
|
public function getCommerceHistoryBudgetChars(): int
|
|
{
|
|
return $this->getInt('commerce_history_budget_chars', 1000);
|
|
}
|
|
|
|
public function getProductSearchKnowledgeChunkLimit(): int
|
|
{
|
|
return $this->getInt('product_search_knowledge_chunk_limit', 6);
|
|
}
|
|
|
|
public function getAdvisoryProductSearchKnowledgeChunkLimit(): int
|
|
{
|
|
return $this->getInt('advisory_product_search_knowledge_chunk_limit', 9);
|
|
}
|
|
|
|
public function getOptimizedShopQueryPrefixPattern(): string
|
|
{
|
|
return $this->getString('optimized_shop_query_prefix_pattern', '/^(?:keywords?|suchquery|search\\s*query|query)\\s*:\\s*/iu');
|
|
}
|
|
|
|
public function getOptimizedShopQueryTrimCharacters(): string
|
|
{
|
|
return $this->getString('optimized_shop_query_trim_characters', " \t\n\r\0\x0B\"'`");
|
|
}
|
|
|
|
private function getInt(string $key, int $default): int
|
|
{
|
|
$value = $this->value($key, $default);
|
|
|
|
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);
|
|
|
|
return is_string($value) && $value !== '' ? $value : $default;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $default
|
|
* @return string[]
|
|
*/
|
|
private function getStringList(string $key, array $default): array
|
|
{
|
|
$value = $this->value($key, $default);
|
|
|
|
if (!is_array($value)) {
|
|
return $default;
|
|
}
|
|
|
|
$out = [];
|
|
|
|
foreach ($value as $item) {
|
|
if (!is_scalar($item)) {
|
|
continue;
|
|
}
|
|
|
|
$item = trim((string) $item);
|
|
|
|
if ($item !== '') {
|
|
$out[] = $item;
|
|
}
|
|
}
|
|
|
|
return $out !== [] ? $out : $default;
|
|
}
|
|
|
|
private function value(string $key, mixed $default): mixed
|
|
{
|
|
$current = $this->config;
|
|
|
|
foreach (explode('.', $key) as $segment) {
|
|
if (!is_array($current) || !array_key_exists($segment, $current)) {
|
|
return $default;
|
|
}
|
|
|
|
$current = $current[$segment];
|
|
}
|
|
|
|
return $current;
|
|
}
|
|
|
|
public function getEmptyPromptMessage(): string
|
|
{
|
|
return $this->getString('messages.empty_prompt', '❌ Empty prompt.');
|
|
}
|
|
|
|
public function getAnalyzeRequestMessage(): string
|
|
{
|
|
return $this->getString('messages.analyze_request', 'Ich analysiere deine Anfrage...');
|
|
}
|
|
|
|
public function getCheckInternetSourcesMessage(): string
|
|
{
|
|
return $this->getString('messages.check_internet_sources', 'Ich prüfe auf Internetquellen...');
|
|
}
|
|
|
|
public function getRetrieveKnowledgeMessage(): string
|
|
{
|
|
return $this->getString('messages.retrieve_knowledge', 'Ich hole relevante Daten aus meinem RAG-Wissen...');
|
|
}
|
|
|
|
public function getOptimizeSearchMessage(): string
|
|
{
|
|
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)');
|
|
}
|
|
|
|
public function getAnalyzeAllInformationMessage(): string
|
|
{
|
|
return $this->getString('messages.analyze_all_information', 'Ich analysiere alle Informationen...');
|
|
}
|
|
|
|
public function getThinkingWhileStreamingMessage(): string
|
|
{
|
|
return $this->getString('messages.thinking_while_streaming', 'Denke nach...');
|
|
}
|
|
|
|
public function getNoLlmDataReceivedMessage(): string
|
|
{
|
|
return $this->getString('messages.no_llm_data_received', '❌ Es wurden keine Daten vom LLM empfangen.');
|
|
}
|
|
|
|
public function getGenericInternalErrorMessage(): string
|
|
{
|
|
return $this->getString('messages.generic_internal_error', '❌ Bei der Verarbeitung der Anfrage ist ein interner Fehler aufgetreten.');
|
|
}
|
|
|
|
public function getDebugInternalErrorPrefix(): string
|
|
{
|
|
return $this->getString('messages.debug_internal_error_prefix', '❌ Interner Fehler: ');
|
|
}
|
|
|
|
public function getExternalUrlSourceLabel(): string
|
|
{
|
|
return $this->getString('source_labels.external_url', 'Externe URL');
|
|
}
|
|
|
|
public function getRagKnowledgeSourceLabel(): string
|
|
{
|
|
return $this->getString('source_labels.rag_knowledge', 'RAG Wissen');
|
|
}
|
|
|
|
public function getConversationHistorySourceLabel(): string
|
|
{
|
|
return $this->getString('source_labels.conversation_history', 'Chatverlauf');
|
|
}
|
|
|
|
public function getShopSystemSourceLabel(): string
|
|
{
|
|
return $this->getString('source_labels.shop_system', 'Shopsystem');
|
|
}
|
|
|
|
public function getExtendedShopSearchSourceLabel(): string
|
|
{
|
|
return $this->getString('source_labels.extended_shop_search', 'Erweiterte Shopsuche');
|
|
}
|
|
|
|
public function getUsedSourcesPrefix(): string
|
|
{
|
|
return $this->getString('source_labels.used_sources_prefix', 'Genutzte Quellen: ');
|
|
}
|
|
|
|
public function getSourcesPrefix(): string
|
|
{
|
|
return $this->getString('source_labels.sources_prefix', 'Quellen: ');
|
|
}
|
|
|
|
public function getSourceBadgeHtmlTemplate(): string
|
|
{
|
|
return $this->getString('html.source_badge_template', '<span class="badge bg-info text-black">%s</span>');
|
|
}
|
|
|
|
public function getErrorHtmlTemplate(): string
|
|
{
|
|
return $this->getString('html.error_template', '<div class="retriex-alert retriex-alert--error"><div class="retriex-alert__icon">❌</div><div class="retriex-alert__content"><div class="retriex-alert__title">Hinweis</div><div class="retriex-alert__text">%s</div></div></div>' . "\n");
|
|
}
|
|
|
|
public function getThinkHtmlTemplate(): string
|
|
{
|
|
return $this->getString('html.think_template', '<span class="text-info think">%s</span>' . "\n");
|
|
}
|
|
|
|
public function getInfoHtmlTemplate(): string
|
|
{
|
|
return $this->getString('html.info_template', "\n\n" . '<span class="text-info fw-bolder">%s</span>' . "\n");
|
|
}
|
|
|
|
public function getDebugHtmlTemplate(): string
|
|
{
|
|
return $this->getString('html.debug_template', "\n\nDEBUG: <code>%s</code>\n");
|
|
}
|
|
|
|
public function getShopPrompt(string $prompt, string $commerceHistoryContext = ''): string
|
|
{
|
|
$historyBlock = '';
|
|
|
|
if (trim($commerceHistoryContext) !== '') {
|
|
$historyBlock = $this->buildHistoryBlock($commerceHistoryContext);
|
|
}
|
|
|
|
return $this->implodePromptBlocks([
|
|
$this->getShopPromptIntro(),
|
|
$this->buildRulesBlock($this->getShopPromptRules()),
|
|
$this->getShopPromptOutputFormatBlock(),
|
|
$historyBlock,
|
|
$this->getCurrentUserInputLabel() . ':',
|
|
trim($prompt),
|
|
]);
|
|
}
|
|
|
|
private function buildHistoryBlock(string $commerceHistoryContext): string
|
|
{
|
|
return $this->implodePromptBlocks([
|
|
$this->getRecentConversationContextLabel() . ':',
|
|
trim($commerceHistoryContext),
|
|
$this->buildRulesBlock($this->getConversationContextRules(), 'Additional rules for conversation context:'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
public function getShopPromptRules(): array
|
|
{
|
|
return $this->getStringList('shop_prompt.rules', [
|
|
'- Output only the final search query.',
|
|
'- Always convert relevant search terms to their singular form.',
|
|
'- No introduction, no explanation, no quotation marks.',
|
|
'- Use only shop-relevant search terms from the user input for a shop search.',
|
|
'- 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/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".',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
public function getConversationContextRules(): array
|
|
{
|
|
return $this->getStringList('shop_prompt.conversation_context_rules', [
|
|
'- The current user input has highest priority.',
|
|
'- Use the recent conversation context only to resolve omitted references.',
|
|
'- Use it only for product carry-over, brand carry-over, model carry-over, or variant follow-ups.',
|
|
'- Do not revive older products unless the current user input clearly refers to them.',
|
|
'- If the current input starts a new topic, ignore older product context.',
|
|
'- Prefer the most recent product reference over older ones.',
|
|
]);
|
|
}
|
|
|
|
public function getShopPromptIntro(): string
|
|
{
|
|
return $this->getString('shop_prompt.intro', 'Generate a short search query for Shopware 6 from the following user input text.');
|
|
}
|
|
|
|
public function getShopPromptOutputFormatBlock(): string
|
|
{
|
|
return $this->getString('shop_prompt.output_format_block', "Output format:\nKeyword1 Keyword2 Keyword3");
|
|
}
|
|
|
|
public function getRecentConversationContextLabel(): string
|
|
{
|
|
return $this->getString('shop_prompt.recent_conversation_context_label', 'RECENT CONVERSATION CONTEXT');
|
|
}
|
|
|
|
public function getCurrentUserInputLabel(): string
|
|
{
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* @param string[] $blocks
|
|
*/
|
|
private function implodePromptBlocks(array $blocks): string
|
|
{
|
|
$normalized = array_values(array_filter(
|
|
array_map(
|
|
static fn(string $block): string => trim($block),
|
|
$blocks
|
|
),
|
|
static fn(string $block): bool => $block !== ''
|
|
));
|
|
|
|
return implode("\n\n", $normalized);
|
|
}
|
|
}
|