Files
MtoRagSystem/src/Config/AgentRunnerConfig.php
2026-04-30 15:11:54 +02:00

906 lines
28 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Config;
final class AgentRunnerConfig
{
private const NO_LLM_MAIN_DEVICE_REQUEST_ROLE_KEYWORDS = [
'anlage',
'messanlage',
'gerät',
'geraet',
'messgerät',
'messgeraet',
'analysegerät',
'analysegeraet',
'analysator',
'analyzer',
'system',
'testomat',
'testomaten',
'testoamt',
'testomate',
'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',
'indikatortyp',
'reagenz',
'reagent',
'reagenzsatz',
'kalibrierlösung',
'kalibrierloesung',
'pufferlösung',
'pufferloesung',
'reinigungslösung',
'reinigungsloesung',
'kalibrier',
'puffer',
'zubehör',
'zubehor',
'accessory',
'ersatzteil',
'verbrauch',
'consumable',
'kit',
'set',
'flasche',
'bottle',
'100 ml',
'500 ml',
'100ml',
'500ml',
];
/**
* @param array<string, mixed> $config
*/
public function __construct(
private readonly array $config = [],
) {
}
public function getCommerceHistoryBudgetChars(): int
{
return $this->getRequiredInt('commerce_history_budget_chars');
}
public function getProductSearchKnowledgeChunkLimit(): int
{
return $this->getRequiredInt('product_search_knowledge_chunk_limit');
}
public function getAdvisoryProductSearchKnowledgeChunkLimit(): int
{
return $this->getRequiredInt('advisory_product_search_knowledge_chunk_limit');
}
public function getOptimizedShopQueryPrefixPattern(): string
{
return $this->getRequiredString('optimized_shop_query_prefix_pattern');
}
public function getOptimizedShopQueryTrimCharacters(): string
{
return $this->getRequiredString('optimized_shop_query_trim_characters');
}
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;
}
private function getRequiredInt(string $key): int
{
$value = $this->requiredValue($key);
if (is_numeric($value)) {
return (int) $value;
}
throw new \InvalidArgumentException(sprintf('RetrieX agent config key "%s" must be numeric.', $key));
}
private function getRequiredString(string $key): string
{
$value = $this->requiredValue($key);
if (is_string($value) && $value !== '') {
return $value;
}
throw new \InvalidArgumentException(sprintf('RetrieX agent config key "%s" must be a non-empty string.', $key));
}
/**
* @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;
}
private function requiredValue(string $key): mixed
{
$current = $this->config;
foreach (explode('.', $key) as $segment) {
if (!is_array($current) || !array_key_exists($segment, $current)) {
throw new \InvalidArgumentException(sprintf('RetrieX agent config key "%s" is required.', $key));
}
$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 kann die Shop-Suche noch nicht belastbar auflösen. Bitte nenne das Produkt, den Messparameter oder das Zubehör etwas konkreter.'
);
}
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 getNoLlmFallbackMaxShopResults(): int
{
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(
'no_llm_fallback.messages.shop_only',
'Ich finde dazu im RAG-Wissen keine belastbare Fachinformation. Aus den Shopdaten ergeben sich folgende Treffer; technische Eignung bitte prüfen:'
);
}
public function getNoLlmFallbackShopWithKnowledgeMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.shop_with_knowledge',
'Es liegen RAG-/Kontexttreffer und Shopdaten vor. Ohne LLM leite ich daraus keine technische Eignung ab. Die Shopdaten zeigen folgende Treffer; technische Eignung bitte prüfen:'
);
}
public function getNoLlmFallbackAccessoryOnlyForMainDeviceMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.accessory_only_for_main_device',
'Die Shop-Treffer wirken wie Zubehör/Verbrauchsmaterial und nicht wie eine angefragte Messanlage oder ein Hauptgerät. Ich werte sie deshalb nicht als passende Hauptlösung.'
);
}
public function getNoLlmFallbackEscalationMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.escalation',
'Für eine verbindliche Produktauswahl sollte der konkrete Anwendungsfall durch Vertrieb oder Support geprüft werden.'
);
}
public function getNoLlmFallbackKnowledgeOnlyMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.knowledge_only',
'Ich habe Treffer im RAG-Wissen gefunden, aber ohne LLM kann ich daraus keine belastbare fachliche Antwort synthetisieren. Ich gebe deshalb keine sichere Produktaussage aus. Bitte aktiviere das LLM oder konkretisiere die Frage für eine gezielte Prüfung.'
);
}
public function getNoLlmFallbackNoDataMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.no_data',
'Ich finde dazu keine belastbaren Daten in den vorliegenden Quellen. Bitte nenne Produkt, Messparameter, Zubehör oder Anwendungsfall genauer.'
);
}
public function getNoLlmFallbackNoShopResultsWithKnowledgeMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.no_shop_results_with_knowledge',
'Ich finde RAG-/Kontexttreffer, aber keine passenden Shop-Treffer zur aktuellen Suchanfrage. Das ist keine Aussage, dass es das Produkt nicht gibt. Ohne LLM gebe ich keine technische Negativaussage aus; bitte prüfe den Suchbegriff oder den Anwendungsfall gezielter.'
);
}
public function getNoLlmFallbackNoShopResultsNoKnowledgeMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.no_shop_results_no_knowledge',
'Ich finde weder belastbares RAG-Wissen noch passende Shop-Treffer zur aktuellen Suchanfrage. Das ist keine sichere Negativaussage. Bitte nenne Produkt, Messparameter oder Zubehör konkreter.'
);
}
/**
* @return string[]
*/
public function getNoLlmMainDeviceRequestRoleKeywords(): array
{
return $this->getStringList(
'no_llm_fallback.product_roles.main_device_request_keywords',
self::NO_LLM_MAIN_DEVICE_REQUEST_ROLE_KEYWORDS
);
}
/**
* @return string[]
*/
public function getNoLlmAccessoryProductRoleKeywords(): array
{
return $this->getStringList(
'no_llm_fallback.product_roles.accessory_product_keywords',
self::NO_LLM_ACCESSORY_PRODUCT_ROLE_KEYWORDS
);
}
public function getNoLlmFallbackShopUnavailableWithKnowledgeMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.shop_unavailable_with_knowledge',
'Live-Shopdaten konnten nicht geladen werden. Ohne Shop-Check treffe ich keine Aussage zu aktueller Verfügbarkeit, Preis oder Shop-Portfolio. Vorhandenes RAG-Wissen darf nur als fachlicher Kontext verstanden werden.'
);
}
public function getNoLlmFallbackShopUnavailableNoKnowledgeMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.shop_unavailable_no_knowledge',
'Live-Shopdaten konnten nicht geladen werden und ich finde kein belastbares RAG-Wissen. Ich kann daraus keine verlässliche Produkt- oder Verfügbarkeitsaussage ableiten.'
);
}
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',
'danach',
'dazu',
'damit',
'dafür',
'dafuer',
'hierzu',
'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',
'ist',
'sind',
'gut',
'geeignet',
'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',
]);
}
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);
}
}