diff --git a/RETRIEX_PATCH_38B_LANGUAGE_CLEANUP_REQUIRED_TERM_HOTFIX_README.md b/RETRIEX_PATCH_38B_LANGUAGE_CLEANUP_REQUIRED_TERM_HOTFIX_README.md new file mode 100644 index 0000000..7323435 --- /dev/null +++ b/RETRIEX_PATCH_38B_LANGUAGE_CLEANUP_REQUIRED_TERM_HOTFIX_README.md @@ -0,0 +1,36 @@ +# RetrieX Patch p38b - Language Cleanup Required Term Hotfix + +## Zweck + +Kleiner Hotfix auf p38. Die Config-/Regression-Validierung erwartet den Pflichtbegriff `dieser` in den effektiven Stopword-Listen der Cleanup-Profile: + +- `commerce_query` +- `rag_evidence` +- `shop_context_fallback` + +p38 hatte die YAML-Externalisierung korrekt umgesetzt, aber diesen Guardrail-Begriff nicht in einer von allen drei Profilen genutzten Stopword-Gruppe bereitgestellt. + +## Änderung + +`config/retriex/language.yaml` + +- `dieser` wurde in `stopword_groups.de_core` ergänzt. +- Dadurch ist der Begriff automatisch in allen drei betroffenen Profilen enthalten, weil sie `de_core` referenzieren. + +## Nicht geändert + +- Keine PHP-Runtime-Logik. +- Keine neuen PHP-Core-Listen. +- Keine Retrieval-/Ranking-/Prompt-Logik. +- Keine fachliche Sonderlogik. + +## Prüfhinweis + +Nach Einspielen erneut ausführen: + +```bash +bin/console mto:agent:config:validate +bin/console mto:agent:regression:test +bin/console mto:agent:config:audit-source --details +bin/console mto:agent:config:audit-patterns --details +``` diff --git a/RETRIEX_PATCH_38_YAML_EXTERNALIZATION_HARDENING_README.md b/RETRIEX_PATCH_38_YAML_EXTERNALIZATION_HARDENING_README.md new file mode 100644 index 0000000..a2aad96 --- /dev/null +++ b/RETRIEX_PATCH_38_YAML_EXTERNALIZATION_HARDENING_README.md @@ -0,0 +1,123 @@ +# RetrieX Patch p38 - YAML Externalization Hardening + +## Ziel + +Dieser Patch schliesst die nach p37 verbliebenen sinnvollen YAML-Externalization-Luecken im PHP-Core. Fokus ist nicht technische Infrastruktur, sondern fachliche, sprachliche und UI-nahe Listen, Tokens, Vergleichswerte, Prompt-Regeln und Normalisierungsregeln. + +Die Leitlinie bleibt: + +- keine neuen Token-/Stringlisten im PHP-Core +- fachliche, sprachliche und UI-nahe Werte ueber YAML +- bestehende 1.5.3-Logik nicht fachlich veraendern +- keine neuen Spezialfaelle fuer konkrete Produkte + +## Geaenderte Dateien + +- `config/retriex/agent.yaml` +- `config/retriex/language.yaml` +- `config/retriex/prompt.yaml` +- `src/Config/AgentRunnerConfig.php` +- `src/Config/LanguageCleanupConfig.php` +- `src/Config/PromptBuilderConfig.php` +- `src/Agent/AgentRunner.php` +- `src/Agent/PromptBuilder.php` +- `src/Knowledge/Retrieval/QueryCleaner.php` +- `src/Knowledge/Retrieval/NdjsonLexicalIndexBuilder.php` +- `src/Knowledge/Retrieval/NdjsonKeywordRetriever.php` +- `src/Knowledge/Retrieval/NdjsonChunkLookup.php` +- `src/Knowledge/Retrieval/NdjsonHybridRetriever.php` + +## Inhalt + +### AgentRunner + +Externalisiert wurden insbesondere: + +- Placeholder-Ausgaben der Query-/Input-Normalisierung +- Shop-Repair-/Shop-Query-Heartbeat-Meldungen +- No-LLM-Produktfeldtexte und Produktzeilen-Templates +- Follow-up-Kontextlabels +- Production-UI-Stage-Labels +- Production-UI-Confidence-Labels +- Production-UI-Texte, Templates und Shop-Meta-Labels +- Follow-up-Action-Chips fuer Commerce und Knowledge + +Zusaetzlich nutzt der Runner vorhandene Language-Cleanup-Konfiguration fuer ASCII-Transliteration, Separator-Normalisierung und Dash-Normalisierung. + +### Language Cleanup + +`language.yaml` enthaelt nun zusaetzlich zentrale Normalisierungsregeln fuer: + +- Wortseparatoren (`-`, `/`, `_`) +- Unicode-Dash-Aequivalente + +`LanguageCleanupConfig` stellt dafuer zentrale Methoden bereit: + +- `replaceWordSeparatorsWithSpace()` +- `normalizeDashEquivalents()` + +### PromptBuilder + +Externalisiert wurden verbliebene Prompt-/Policy-Regeln rund um: + +- Fallback-Escalation bei vorhandenen Shop-Ergebnissen +- Measurement-Evidence-Guard-Templates +- finale Measurement-Evidence-Regeln +- Parameter-Split-Pattern +- Parameter-Trim-Zeichen + +Die Unicode-Dash-Normalisierung nutzt nun ebenfalls `LanguageCleanupConfig`. + +### Retrieval-Normalisierung + +Wiederholte Separatorlisten in Retrieval-Klassen wurden durch die zentrale Language-Cleanup-Methode ersetzt. + +Betroffen: + +- `QueryCleaner` +- `NdjsonLexicalIndexBuilder` +- `NdjsonKeywordRetriever` +- `NdjsonChunkLookup` +- `NdjsonHybridRetriever` + +## Bewusst nicht externalisiert + +Nicht Ziel dieses Patches waren rein technische Infrastrukturwerte, z. B.: + +- Boolean-Parser-Werte wie `true`, `false`, `yes`, `no`, `on`, `off` +- technische Statuscodes und interne State-Keys +- DTO-/Array-Schluessel +- Log-/HTTP-/Encoding-Infrastruktur +- Zeilenumbruch-Normalisierung + +Diese Werte sind keine fachlichen oder sprachlichen Matching-/Prompt-/UI-Listen. + +## Lokale Validierung + +Da im ZIP kein `vendor/` enthalten ist, konnten Symfony-Console-Checks nicht direkt ausgefuehrt werden. + +Durchgefuehrt wurden: + +- `php -l` fuer alle geaenderten PHP-Dateien: OK +- YAML-Parse fuer geaenderte YAML-Dateien: OK +- statische Greps auf die zuvor identifizierten Reststellen: OK + +Gepruefte ehemalige Problemklassen: + +- keine PHP-Funde mehr fuer `normalized user input` / `corrected user input` +- keine PHP-Funde mehr fuer die harten Measurement-Evidence-Regeltexte +- keine PHP-Funde mehr fuer die alten Separatorlisten `['-', '/', '_']` +- keine PHP-Funde mehr fuer die alten Unicode-Dash-Listen +- keine PHP-Funde mehr fuer die AgentRunner-UI-Labels wie `RetrieX-Status`, `Datenbasis`, `Folgeaktionen`, `Artikelnummer`, `Preis`, `Verfuegbarkeit`, `Relevanz` + +## Empfohlene Checks nach Einspielen + +Bitte im echten Projekt mit installiertem `vendor/` ausfuehren: + +```bash +bin/console mto:agent:config:validate +bin/console mto:agent:regression:test +bin/console mto:agent:config:audit-source --details +bin/console mto:agent:config:audit-patterns --details +``` + diff --git a/config/retriex/agent.yaml b/config/retriex/agent.yaml index c81ceba..cb59573 100644 --- a/config/retriex/agent.yaml +++ b/config/retriex/agent.yaml @@ -16,6 +16,12 @@ parameters: max_length_ratio_percent: 150 heartbeat_message: 'Ich optimiere die Anfrage…' output_prefix_pattern: '/^(?:normalisiert|korrigiert|corrected|normalized)\s*:\s*/iu' + placeholder_outputs: + - 'normalized user input' + - 'corrected user input' + - 'user input' + - 'normalisierte nutzereingabe' + - 'korrigierte nutzereingabe' skip_patterns: - '/https?:\/\//iu' - '/\bwww\./iu' @@ -188,6 +194,10 @@ parameters: history_question_pattern: '/^Question:\s*(.+)$/mi' history_turn_split_pattern: '/(?=^Question:\s)/m' history_question_strip_pattern: '/^Question:\s*.*(?:\R|$)/u' + context_labels: + previous_user_question_template: 'Vorherige Nutzerfrage: {question}' + previous_reference_anchors_template: 'Vorherige technische Referenzanker (nur zur Referenzauflösung, keine Faktenquelle): {anchors}' + current_follow_up_question_template: 'Aktuelle Folgefrage: {question}' reference_anchor: testomat_model_pattern: '/\bTestomat(?:®)?\s+(?:\d{3,4}(?:\s+[A-Z]{2,8})?|EVO(?:\s+[A-Z]{2,6})?|ECO(?:[-\s]?(?:PLUS|C))?|DUO(?:\s+\d{3,4})?|LAB(?:\s+[A-Z]{2,6})?)\b/iu' hardness_value_pattern: '/\b\d+(?:[,.]\d+)?\s*°\s*dH\b/iu' @@ -203,6 +213,8 @@ parameters: analyze_all_information: 'Ich analysiere alle Informationen...' thinking_while_streaming: 'Denke nach...' no_llm_data_received: '❌ Es wurden keine Daten vom LLM empfangen.' + shop_repair_check: 'Erweiterte Shopsuche wird geprüft…' + shop_query_optimization_heartbeat: 'Shop-Suchanfrage wird optimiert…' generic_internal_error: '❌ Bei der Verarbeitung der Anfrage ist ein interner Fehler aufgetreten.' debug_internal_error_prefix: '❌ Interner Fehler: ' @@ -306,6 +318,21 @@ parameters: 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.' shop_unavailable_with_knowledge: 'Live-Shopdaten konnten nicht geladen werden. Ich kann keine Aussage zu aktueller Verfügbarkeit, Preis oder Shop-Portfolio treffen. Wenn das RAG-Wissen einen direkten Fachbeleg enthält, wird die fachliche Antwort davon getrennt betrachtet.' shop_unavailable_no_knowledge: 'Live-Shopdaten konnten nicht geladen werden und die RAG-Treffer enthalten keinen direkten Fachbeleg zur Anfrage. Ich kann daraus keine verlässliche Produkt-, Verfügbarkeits- oder Portfolioaussage ableiten.' + product_fields: + unreadable_results_message: '- Es wurden Shop-Treffer übergeben, aber keine lesbaren Produktdaten gefunden.' + unnamed_product: 'Unbenanntes Shop-Produkt' + product_number_template: 'Art.-Nr. {value}' + manufacturer_template: 'Hersteller: {value}' + price_template: 'Preis: {value}' + availability_template: 'Verfügbar: {value}' + availability_yes: 'ja' + availability_no: 'nein' + url_template: 'URL: {value}' + incompatible_role_note: 'Hinweis: Zubehör/Verbrauchsartikel; nicht als Messanlage/Gerät bestätigt' + line_template: '{index}. {parts}' + separator: ' | ' + unavailable_reason_template: '{message} Ursache: {reason}' + product_roles: main_device_request_keywords: - anlage @@ -354,6 +381,125 @@ parameters: - 100ml - 500ml + + production_ui: + stage_labels: + preparing_answer: 'Antwort wird vorbereitet' + shop_routing_detected: 'Shop-Routing erkannt' + rag_searched: 'RAG-Wissen wurde durchsucht' + shop_search_preparing: 'Shop-Suche wird vorbereitet' + more_context_needed: 'Mehr Kontext nötig' + shop_search_running: 'Shop wird durchsucht' + shop_unavailable: 'Shopdaten nicht verfügbar' + shop_completed: 'Shop-Suche abgeschlossen' + answer_generating: 'Antwort wird generiert' + completed: 'Abgeschlossen' + interrupted: 'Antwort wurde unterbrochen' + confidence_labels: + checking_evidence: 'Beleglage wird geprüft' + checking_shop_data: 'Shopdaten werden geprüft' + more_context_needed: 'mehr Kontext nötig' + interrupted: 'nicht abgeschlossen' + direct: 'fachlich belegt' + aggregate_missing: 'geprüfte Quellen, keine passende Zählinformation' + weak: 'RAG-Näherungstreffer, kein direkter Fachbeleg' + default: 'noch keine belastbaren Treffer' + direct_shop_check: 'fachlich belegt; Shopdaten werden geprüft' + aggregate_missing_shop_check: 'geprüfte Quellen ohne Zählinformation; Shopdaten werden geprüft' + weak_shop_check: 'RAG-Näherungstreffer; Shopdaten werden geprüft' + default_shop_check: 'Shopdaten werden geprüft' + aggregate_missing_shop_unavailable: 'geprüfte Quellen ohne Zählinformation; Shopdaten nicht verfügbar' + aggregate_missing_no_count: 'geprüfte Quellen, keine passende Zählinformation' + shop_unavailable_with_knowledge: 'fachlich belegt; Shopdaten nicht verfügbar' + shop_unavailable: 'Shopdaten nicht verfügbar' + rag_and_shop: 'RAG + Shopdaten' + shop_only: 'nur Shopdaten' + rag_no_shop_hits: 'RAG-Wissen, keine Shop-Treffer' + no_reliable_data: 'keine belastbaren Daten' + no_reliable_hits: 'noch keine belastbaren Treffer' + text: + live_shop_source_plain_label: 'Live-Shopdaten' + run_status_eyebrow: 'RetrieX-Status' + evidence_prefix: 'Beleglage: ' + data_basis_label: 'Datenbasis' + data_basis_empty_completed: 'keine belastbare Datenbasis' + data_basis_empty_running: 'wird geprüft' + rag_hits_checking: 'RAG-Treffer: wird geprüft' + shop_hits_loading: 'Shop-Treffer: wird geladen' + shop_hits_unavailable: 'Shop-Treffer: nicht verfügbar' + shop_hits_no_query: 'Shop-Treffer: keine Suchquery' + shop_hits_not_requested: 'Shop-Treffer: nicht angefragt' + status_completed: 'Status: abgeschlossen' + status_running: 'Status: läuft' + shop_results_eyebrow: 'Shop-Ergebnisse' + shop_results_title: 'Shop-Ergebnisse' + evaluated_query_label: 'Ausgewertete Suchquery' + unnamed_product: 'Unbenanntes Produkt' + field_not_provided: 'nicht übermittelt' + product_number_label: 'Artikelnummer' + price_label: 'Preis' + availability_label: 'Verfügbarkeit' + manufacturer_label: 'Hersteller' + relevance_label: 'Relevanz' + availability_yes: 'verfügbar' + availability_no: 'nicht verfügbar' + availability_unknown: 'Shopstatus nicht übermittelt' + followup_eyebrow: 'Folgeaktionen' + followup_title: 'Was möchtest du als Nächstes tun?' + shop_meta_fallback_query: 'keine Suchquery ermittelt' + shop_meta_query_mode_optimized: 'optimiert' + shop_meta_query_mode_direct: 'direkt' + shop_meta_default_intent: 'commerce' + shop_meta_title_unavailable: 'Shopdaten nicht verfügbar' + shop_meta_title_completed: 'Shop-Suche abgeschlossen' + shop_meta_title_running: 'Shop-Suche wird ausgeführt' + shop_meta_status_completed: 'Status: abgeschlossen' + shop_meta_status_running: 'Status: läuft' + shop_meta_result_unavailable: 'Shoptreffer: nicht verfügbar' + shop_meta_result_loading: 'Shoptreffer: wird geladen' + shop_meta_repair_used: 'Erweiterte Suche: genutzt' + shop_meta_repair_checked: 'Erweiterte Suche: geprüft' + shop_meta_eyebrow: 'Shop-Suche' + shop_meta_query_label: 'Gesendete Suchquery' + shop_meta_query_prefix: 'Query: ' + shop_meta_intent_prefix: 'Intent: ' + shop_unavailable_default_reason: 'Keine Detailmeldung vom Shopware-Server.' + shop_unavailable_title: 'Shopdaten konnten nicht geladen werden' + shop_unavailable_text_prefix: 'RetrieX antwortet ohne Live-Shopdaten weiter. Ursache: ' + no_llm_history_default: 'Es wurden keine Daten vom LLM empfangen.' + history_notice_default_title: 'Systemhinweis' + history_notice_shop_unavailable_title: 'Shopdaten konnten nicht geladen werden' + history_notice_answer_incomplete_title: 'Antwort konnte nicht abgeschlossen werden' + templates: + rag_hits_count: 'RAG-Treffer: {count}' + shop_hits_count: 'Shop-Treffer: {count}' + shop_results_summary: '{count} Shop-Treffer ausgewertet' + shop_results_top_displayed_suffix: ' · Top {max} angezeigt' + shop_results_repair_suffix: ' · erweiterte Shopsuche genutzt' + relevance_matched_queries: 'Gefunden über: {queries}' + relevance_highlight: 'Passender Shop-Hinweis: {highlight}' + relevance_match_source: 'Trefferquelle: {source}' + relevance_query: 'Passend zur Suchquery: {query}' + relevance_default: 'Aus den Live-Shopdaten übernommen' + shop_meta_result_count: 'Shoptreffer: {count}' + history_notice_without_detail: 'Systemhinweis: {title}.' + history_notice_with_detail: 'Systemhinweis: {title}. Ursache: {detail}' + shop_results: + max_cards: 5 + follow_up_actions: + commerce: + - label: 'Im Shop suchen' + prompt: 'Suche die aktuelle Produktauswahl im Shop.' + - label: 'Nur Zubehör anzeigen' + prompt: 'Zeige aus der aktuellen Produktauswahl nur Zubehör.' + - label: 'Nur Geräte anzeigen' + prompt: 'Zeige aus der aktuellen Produktauswahl nur Geräte.' + - label: 'Preis anzeigen' + prompt: 'Zeige mir die Preise der aktuell relevanten Produkte.' + knowledge: + - label: 'Technische Details anzeigen' + prompt: 'Zeige technische Details zur aktuellen Antwort.' + source_labels: external_url: 'Externe URL' rag_knowledge: 'RAG Wissen' diff --git a/config/retriex/language.yaml b/config/retriex/language.yaml index 04d37e6..6fadf98 100644 --- a/config/retriex/language.yaml +++ b/config/retriex/language.yaml @@ -5,7 +5,6 @@ parameters: words: - mit - der - - dieser - die - das - ein @@ -80,11 +79,20 @@ parameters: ö: oe ü: ue ß: ss + word_separator_chars: + - '-' + - '/' + - '_' + dash_equivalents: + - '‐' + - '‑' + - '‒' + - '–' + - '—' stopword_groups: de_core: - der - - dieser - die - das - den @@ -94,6 +102,7 @@ parameters: - eine - einer - eines + - dieser - einen - einem - und diff --git a/config/retriex/prompt.yaml b/config/retriex/prompt.yaml index cab3c88..554d450 100644 --- a/config/retriex/prompt.yaml +++ b/config/retriex/prompt.yaml @@ -256,6 +256,29 @@ parameters: - stoerungsfrei generic_safe_no_evidence_answer_template_de: Ich finde in den bereitgestellten Quellen keinen sicher belegten Treffer für die Messung von {label}. generic_safe_no_accessory_evidence_answer_template_de: Ich finde in den bereitgestellten Quellen keinen sicher belegten Indikator oder ein Reagenz für die Messung von {label}. + rule_templates: + shop_positive_evidence: '- Shop record {index} ({product}): explicit positive evidence for {label} is present in this same record.' + shop_no_evidence: '- No shop product record shown to the model contains explicit positive evidence for {label} in the same record.' + unnamed_product: 'unnamed product' + default_requested_parameter_label: 'requested measurement parameter' + shop_record_positive_evidence_line: 'Requested measurement evidence: explicit positive evidence for {label} is present in this same SHOP PRODUCT RECORD.' + shop_record_no_evidence_line: 'Requested measurement evidence: no explicit positive evidence for {label} is present in this SHOP PRODUCT RECORD. Do not present this record as technically suitable for that measurement parameter.' + requested_parameter: '- User requested measurement parameter: {label}.' + positive_terms: '- Positive parameter terms for this request: {terms}.' + positive_context_terms: '- These parameter terms count as suitability evidence only in a measurement-purpose context such as: {terms}.' + negative_context_terms: '- These contexts are not suitability evidence by themselves: {terms}.' + non_equivalent_terms: '- Terms that must NOT be treated as equivalent positive evidence: {terms}.' + rag_url_evidence_scan: '- RAG/URL evidence scan for this exact parameter: {state}.' + rag_url_evidence_found: 'explicit positive evidence found.' + rag_url_evidence_missing: 'no explicit positive evidence found.' + deterministic_scan_no_product_specific_evidence: '- The deterministic exact-term scan did not find product-specific evidence. The answer may still use a clearly equivalent named measurement parameter from the same source record, but must not infer suitability from generic categories, document titles, tags, search terms, neighbouring products, or broad umbrella-topic wording.' + mandatory_no_recommendation: '- Mandatory answer behavior: do not recommend a product as suitable for this measurement parameter.' + start_answer_meaning: '- Start the answer with this meaning in the user language: {answer}' + accessory_mismatch: '- Do not recommend accessories for a different measurement parameter just because they are accessories. If only accessories for other parameters are present, say that only non-matching accessory hits were found.' + commercial_hits_only: '- You may list exact shop hits only as commercial/search hits under a heading such as "Shop-Treffer (technische Eignung nicht sicher belegt)".' + final_rules: + - '- Do not output measurement ranges, methods, application areas, advantages, or alternative suitable models unless the same source record contains explicit positive evidence for the requested measurement parameter.' + - '- The generated shop search query, search intent, ranking position, and user question are not factual evidence for product suitability.' parameters: - id: ph label: pH / pH-Wert @@ -421,6 +444,7 @@ parameters: - '- A negative answer is allowed only when the provided sources explicitly support that negative finding for the asked scope.' - '- If several products, parameters, or accessories could match, ask one focused clarification question instead of guessing.' - '- For risky or binding product selection, state that sales or support should verify the application before a final selection.' + provided_shop_results_context_rule: '- Treat shop results as provided context only; do not imply that a live shop check was performed in this run.' without_shop_check_rules: - '- If the question is product-related and no live shop check was performed in this run, do not make a portfolio-wide negative statement such as "there is no product".' - '- Phrase missing evidence narrowly, for example: "Im RAG-Wissen finde ich dazu keine belastbare Information."' @@ -457,6 +481,10 @@ parameters: - '- State that live shop data could not be loaded. If retrieved knowledge or URL content contains a direct Fachbeleg, still answer the factual part from that source and clearly separate it from missing shop data.' - '- Do not draw negative conclusions about current product availability, price, or shop portfolio while the shop is unavailable.' + parameter_parsing: + split_pattern: '/\s*(?:,|;|\/|\boder\b|\bund\b|\bor\b|\band\b)\s*/iu' + trim_characters: " \t\n\r\0\x0B-–—:()[]{}\"'`“”„" + response_format: base_rules: - '- Keep normal spacing between all words. Never fuse words together.' diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index a4cc508..ab690e3 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -83,7 +83,7 @@ final readonly class AgentRunner yield $this->systemMsg( $this->buildProductionUiMetaMessage( - stageLabel: 'Antwort wird vorbereitet', + stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('preparing_answer'), ragCount: null, shopCount: null, shopCountMode: $this->resolveShopCountModeForMeta( @@ -93,7 +93,7 @@ final readonly class AgentRunner shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery ), sourceLabels: $sources, - confidenceLabel: 'Beleglage wird geprüft' + confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('checking_evidence') ), 'meta' ); @@ -141,7 +141,7 @@ final readonly class AgentRunner if ($this->isCommerceIntent($commerceIntent)) { yield $this->systemMsg( $this->buildProductionUiMetaMessage( - stageLabel: 'Shop-Routing erkannt', + stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_routing_detected'), ragCount: null, shopCount: null, shopCountMode: $this->resolveShopCountModeForMeta( @@ -151,7 +151,7 @@ final readonly class AgentRunner shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery ), sourceLabels: $sources, - confidenceLabel: 'Shopdaten werden geprüft' + confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('checking_shop_data') ), 'meta' ); @@ -174,7 +174,7 @@ final readonly class AgentRunner yield $this->systemMsg( $this->buildProductionUiMetaMessage( - stageLabel: 'RAG-Wissen wurde durchsucht', + stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('rag_searched'), ragCount: count($knowledgeChunks), shopCount: null, shopCountMode: $this->resolveShopCountModeForMeta( @@ -202,7 +202,7 @@ final readonly class AgentRunner if ($this->isCommerceIntent($commerceIntent)) { yield $this->systemMsg( $this->buildProductionUiMetaMessage( - stageLabel: 'Shop-Suche wird vorbereitet', + stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_search_preparing'), ragCount: count($knowledgeChunks), shopCount: null, shopCountMode: 'loading', @@ -308,7 +308,7 @@ final readonly class AgentRunner yield $this->systemMsg( $this->buildProductionUiMetaMessage( - stageLabel: 'Mehr Kontext nötig', + stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('more_context_needed'), ragCount: count($knowledgeChunks), shopCount: null, shopCountMode: $this->resolveShopCountModeForMeta( @@ -318,7 +318,7 @@ final readonly class AgentRunner shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery ), sourceLabels: $sources, - confidenceLabel: 'mehr Kontext nötig', + confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('more_context_needed'), completed: true ), 'meta' @@ -370,7 +370,7 @@ final readonly class AgentRunner yield $this->systemMsg( $this->buildProductionUiMetaMessage( - stageLabel: 'Shop wird durchsucht', + stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_search_running'), ragCount: count($knowledgeChunks), shopCount: null, shopCountMode: 'loading', @@ -420,7 +420,7 @@ final readonly class AgentRunner 'meta' ); $historyNotices[] = $this->buildHistoryNotice( - 'Shopdaten konnten nicht geladen werden', + $this->agentRunnerConfig->getProductionUiText('history_notice_shop_unavailable_title'), $primaryShopSearchFailureReason ); @@ -431,7 +431,7 @@ final readonly class AgentRunner 'repairQueries' => [], ]; } else { - yield $this->systemMsg('Erweiterte Shopsuche wird geprüft…', 'think'); + yield $this->systemMsg($this->agentRunnerConfig->getShopRepairCheckMessage(), 'think'); $repairPayload = $this->repairShopResults( prompt: $prompt, @@ -476,7 +476,7 @@ final readonly class AgentRunner yield $this->systemMsg( $this->buildProductionUiMetaMessage( - stageLabel: $primaryShopSearchHadSystemFailure ? 'Shopdaten nicht verfügbar' : 'Shop-Suche abgeschlossen', + stageLabel: $primaryShopSearchHadSystemFailure ? $this->agentRunnerConfig->getProductionUiStageLabel('shop_unavailable') : $this->agentRunnerConfig->getProductionUiStageLabel('shop_completed'), ragCount: count($knowledgeChunks), shopCount: $primaryShopSearchHadSystemFailure ? null : count($shopResults), shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : 'count', @@ -542,7 +542,7 @@ final readonly class AgentRunner yield $this->systemMsg( $this->buildProductionUiMetaMessage( - stageLabel: 'Antwort wird generiert', + stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('answer_generating'), ragCount: count($knowledgeChunks), shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null), shopCountMode: $this->resolveShopCountModeForMeta( @@ -587,7 +587,7 @@ final readonly class AgentRunner yield $this->systemMsg( $this->buildProductionUiMetaMessage( - stageLabel: 'Abgeschlossen', + stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('completed'), ragCount: count($knowledgeChunks), shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null), shopCountMode: $this->resolveShopCountModeForMeta( @@ -667,7 +667,7 @@ final readonly class AgentRunner $userErrorMessage = $this->buildUserErrorMessage($e); yield $this->systemMsg( $this->buildProductionUiMetaMessage( - stageLabel: 'Antwort wurde unterbrochen', + stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('interrupted'), ragCount: count($knowledgeChunks), shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null), shopCountMode: $this->resolveShopCountModeForMeta( @@ -677,7 +677,7 @@ final readonly class AgentRunner shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery ), sourceLabels: $sources, - confidenceLabel: 'nicht abgeschlossen', + confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('interrupted'), completed: true ), 'meta' @@ -686,7 +686,7 @@ final readonly class AgentRunner $historyResponse = $this->buildHistoryResponse('', array_merge( $historyNotices, - [$this->buildHistoryNotice('Antwort konnte nicht abgeschlossen werden', $e->getMessage())] + [$this->buildHistoryNotice($this->agentRunnerConfig->getProductionUiText('history_notice_answer_incomplete_title'), $e->getMessage())] )); if ($historyResponse !== '') { @@ -985,12 +985,7 @@ final readonly class AgentRunner private function normalizeFuzzyRoutingToken(string $token): string { $token = mb_strtolower(trim($token), 'UTF-8'); - $token = strtr($token, [ - 'ä' => 'ae', - 'ö' => 'oe', - 'ü' => 'ue', - 'ß' => 'ss', - ]); + $token = $this->languageCleanupConfig->transliterateToAscii($token); $token = preg_replace('/[^a-z0-9]+/u', '', $token) ?? $token; return trim($token); @@ -1028,13 +1023,10 @@ final readonly class AgentRunner { $normalized = $this->normalizeRoutingComparisonText($candidate); - return in_array($normalized, [ - 'normalized user input', - 'corrected user input', - 'user input', - 'normalisierte nutzereingabe', - 'korrigierte nutzereingabe', - ], true); + return in_array($normalized, array_map( + fn (string $placeholder): string => $this->normalizeRoutingComparisonText($placeholder), + $this->agentRunnerConfig->getInputNormalizationPlaceholderOutputs() + ), true); } private function normalizeRoutingComparisonText(string $value): string @@ -1132,7 +1124,7 @@ final readonly class AgentRunner $lines = []; foreach ($previousQuestions as $question) { - $lines[] = 'Vorherige Nutzerfrage: ' . $question; + $lines[] = $this->renderAgentTemplate($this->agentRunnerConfig->getFollowUpContextPreviousUserQuestionTemplate(), ['question' => $question]); } if ($referenceAnchors !== []) { @@ -1140,7 +1132,7 @@ final readonly class AgentRunner . implode(' ', $referenceAnchors); } - $lines[] = 'Aktuelle Folgefrage: ' . $prompt; + $lines[] = $this->renderAgentTemplate($this->agentRunnerConfig->getFollowUpContextCurrentQuestionTemplate(), ['question' => $prompt]); return implode("\n", $lines); } @@ -1354,7 +1346,7 @@ final readonly class AgentRunner private function normalizeFollowUpText(string $value): string { $value = mb_strtolower(trim($value), 'UTF-8'); - $value = str_replace(['-', '/', '_'], ' ', $value); + $value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value); $value = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value; $value = preg_replace('/\s+/u', ' ', $value) ?? $value; @@ -1389,7 +1381,7 @@ final readonly class AgentRunner } if (time() - $lastHeartbeatAt >= 2) { - yield $this->systemMsg('Shop-Suchanfrage wird optimiert…', 'think'); + yield $this->systemMsg($this->agentRunnerConfig->getShopQueryOptimizationHeartbeatMessage(), 'think'); $lastHeartbeatAt = time(); } @@ -2027,7 +2019,7 @@ final readonly class AgentRunner private function tokenizeShopQueryCandidate(string $value): array { $value = mb_strtolower(trim($value), 'UTF-8'); - $value = str_replace(['-', '/', '_'], ' ', $value); + $value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value); if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', $value, $matches) !== 1) { return []; @@ -2077,7 +2069,7 @@ final readonly class AgentRunner private function tokenizeMetaGuardText(string $value): array { $value = mb_strtolower(trim($value), 'UTF-8'); - $value = str_replace(['-', '/', '_'], ' ', $value); + $value = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($value); $value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value; $value = preg_replace('/\s+/u', ' ', $value) ?? $value; $value = trim($value); @@ -2232,7 +2224,10 @@ final readonly class AgentRunner } $template = $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTemplate(); - $enriched = str_replace(['{anchor}', '{query}'], [$anchor, $query], $template); + $enriched = $this->renderAgentTemplate($template, [ + 'anchor' => $anchor, + 'query' => $query, + ]); $enriched = preg_replace('/\s+/u', ' ', $enriched) ?? $enriched; return trim($enriched) !== '' ? trim($enriched) : $query; @@ -2505,7 +2500,10 @@ final readonly class AgentRunner : $this->agentRunnerConfig->getNoLlmFallbackShopUnavailableNoKnowledgeMessage(); if ($reason !== '') { - $message .= ' Ursache: ' . $reason; + $message = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('unavailable_reason_template'), [ + 'message' => $message, + 'reason' => $reason, + ]); } return trim($message); @@ -2542,7 +2540,7 @@ final readonly class AgentRunner } if ($lines === []) { - return ['- Es wurden Shop-Treffer übergeben, aber keine lesbaren Produktdaten gefunden.']; + return [$this->agentRunnerConfig->getNoLlmProductField('unreadable_results_message')]; } return $lines; @@ -2554,33 +2552,36 @@ final readonly class AgentRunner $productRole = $this->resolveNoLlmShopProductRole($product); $name = $this->normalizeOneLine($product->name); - $parts[] = $name !== '' ? $name : 'Unbenanntes Shop-Produkt'; + $parts[] = $name !== '' ? $name : $this->agentRunnerConfig->getNoLlmProductField('unnamed_product'); if ($product->productNumber !== null && trim($product->productNumber) !== '') { - $parts[] = 'Art.-Nr. ' . $this->normalizeOneLine($product->productNumber); + $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('product_number_template'), ['value' => $this->normalizeOneLine($product->productNumber)]); } if ($product->manufacturer !== null && trim($product->manufacturer) !== '') { - $parts[] = 'Hersteller: ' . $this->normalizeOneLine($product->manufacturer); + $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('manufacturer_template'), ['value' => $this->normalizeOneLine($product->manufacturer)]); } if ($product->price !== null && trim($product->price) !== '') { - $parts[] = 'Preis: ' . $this->normalizeOneLine($product->price); + $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('price_template'), ['value' => $this->normalizeOneLine($product->price)]); } if ($product->available !== null) { - $parts[] = 'Verfügbar: ' . ($product->available ? 'ja' : 'nein'); + $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('availability_template'), ['value' => $product->available ? $this->agentRunnerConfig->getNoLlmProductField('availability_yes') : $this->agentRunnerConfig->getNoLlmProductField('availability_no')]); } if ($product->url !== null && trim($product->url) !== '') { - $parts[] = 'URL: ' . $this->normalizeOneLine($product->url); + $parts[] = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('url_template'), ['value' => $this->normalizeOneLine($product->url)]); } if ($requestedProductRole === 'main_device_or_system' && $productRole === 'accessory_or_consumable') { - $parts[] = 'Hinweis: Zubehör/Verbrauchsartikel; nicht als Messanlage/Gerät bestätigt'; + $parts[] = $this->agentRunnerConfig->getNoLlmProductField('incompatible_role_note'); } - return sprintf('%d. %s', $index, implode(' | ', $parts)); + return $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('line_template'), [ + 'index' => (string) $index, + 'parts' => implode($this->agentRunnerConfig->getNoLlmProductField('separator'), $parts), + ]); } /** @@ -2722,20 +2723,20 @@ final readonly class AgentRunner private function resolveRagEvidenceConfidenceLabel(string $knowledgeEvidenceState): string { return match ($knowledgeEvidenceState) { - 'direct' => 'fachlich belegt', - 'aggregate_missing' => 'geprüfte Quellen, keine passende Zählinformation', - 'weak' => 'RAG-Näherungstreffer, kein direkter Fachbeleg', - default => 'noch keine belastbaren Treffer', + 'direct' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct'), + 'aggregate_missing' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing'), + 'weak' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('weak'), + default => $this->agentRunnerConfig->getProductionUiConfidenceLabel('default'), }; } private function resolveRagEvidenceShopCheckConfidenceLabel(string $knowledgeEvidenceState): string { return match ($knowledgeEvidenceState) { - 'direct' => 'fachlich belegt; Shopdaten werden geprüft', - 'aggregate_missing' => 'geprüfte Quellen ohne Zählinformation; Shopdaten werden geprüft', - 'weak' => 'RAG-Näherungstreffer; Shopdaten werden geprüft', - default => 'Shopdaten werden geprüft', + 'direct' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct_shop_check'), + 'aggregate_missing' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_shop_check'), + 'weak' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('weak_shop_check'), + default => $this->agentRunnerConfig->getProductionUiConfidenceLabel('default_shop_check'), }; } @@ -2902,13 +2903,8 @@ final readonly class AgentRunner { $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 = $this->languageCleanupConfig->normalizeDashEquivalents($value); + $value = $this->languageCleanupConfig->transliterateToAscii($value); $value = preg_replace('/\s+/u', ' ', $value) ?? $value; return trim($value); @@ -2957,7 +2953,7 @@ final readonly class AgentRunner $noLlmMessage = $this->plainTextFromHtml($this->agentRunnerConfig->getNoLlmDataReceivedMessage()); if ($noLlmMessage === '') { - $noLlmMessage = 'Es wurden keine Daten vom LLM empfangen.'; + $noLlmMessage = $this->agentRunnerConfig->getProductionUiText('no_llm_history_default'); } $parts[] = 'Systemhinweis: ' . $noLlmMessage; @@ -2972,18 +2968,21 @@ final readonly class AgentRunner $detail = $this->normalizeOneLine($this->plainTextFromHtml((string) $detail)); if ($title === '') { - $title = 'Systemhinweis'; + $title = $this->agentRunnerConfig->getProductionUiText('history_notice_default_title'); } if ($detail === '') { - return 'Systemhinweis: ' . $title . '.'; + return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('history_notice_without_detail'), ['title' => $title]); } if (mb_strlen($detail, 'UTF-8') > 500) { $detail = rtrim(mb_substr($detail, 0, 497, 'UTF-8')) . '...'; } - return 'Systemhinweis: ' . $title . '. Ursache: ' . $detail; + return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('history_notice_with_detail'), [ + 'title' => $title, + 'detail' => $detail, + ]); } private function plainTextFromHtml(string $value): string @@ -3033,32 +3032,34 @@ final readonly class AgentRunner ): string { $state = $completed ? 'completed' : 'running'; $ragLabel = $ragCount === null - ? 'RAG-Treffer: wird geprüft' - : 'RAG-Treffer: ' . max(0, $ragCount); + ? $this->agentRunnerConfig->getProductionUiText('rag_hits_checking') + : $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('rag_hits_count'), ['count' => (string) max(0, $ragCount)]); $shopLabel = match ($shopCountMode) { - 'count' => 'Shop-Treffer: ' . max(0, (int) $shopCount), - 'loading' => 'Shop-Treffer: wird geladen', - 'unavailable' => 'Shop-Treffer: nicht verfügbar', - 'not_resolved' => 'Shop-Treffer: keine Suchquery', - default => 'Shop-Treffer: nicht angefragt', + 'count' => $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_hits_count'), ['count' => (string) max(0, (int) $shopCount)]), + 'loading' => $this->agentRunnerConfig->getProductionUiText('shop_hits_loading'), + 'unavailable' => $this->agentRunnerConfig->getProductionUiText('shop_hits_unavailable'), + 'not_resolved' => $this->agentRunnerConfig->getProductionUiText('shop_hits_no_query'), + default => $this->agentRunnerConfig->getProductionUiText('shop_hits_not_requested'), }; - $statusLabel = $completed ? 'Status: abgeschlossen' : 'Status: läuft'; + $statusLabel = $completed + ? $this->agentRunnerConfig->getProductionUiText('status_completed') + : $this->agentRunnerConfig->getProductionUiText('status_running'); $sources = $this->formatProductionUiSourceLabels($sourceLabels); $html = '
' - . '
RetrieX-Status
' + . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('run_status_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
' . htmlspecialchars($stageLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
' . '' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . '' . htmlspecialchars($ragLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . '' . htmlspecialchars($shopLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' - . 'Beleglage: ' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' + . '' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('evidence_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . '
'; if ($sources !== []) { - $html .= '
Datenbasis
'; + $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('data_basis_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; foreach ($sources as $source) { $html .= '' . htmlspecialchars($source, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; @@ -3066,8 +3067,10 @@ final readonly class AgentRunner $html .= '
'; } else { - $emptySourceLabel = $completed ? 'keine belastbare Datenbasis' : 'wird geprüft'; - $html .= '
Datenbasis
' + $emptySourceLabel = $completed + ? $this->agentRunnerConfig->getProductionUiText('data_basis_empty_completed') + : $this->agentRunnerConfig->getProductionUiText('data_basis_empty_running'); + $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('data_basis_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . htmlspecialchars($emptySourceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; } @@ -3087,35 +3090,37 @@ final readonly class AgentRunner ): string { if ($knowledgeEvidenceState === 'aggregate_missing' && !$hasShopResults) { return $shopSearchHadSystemFailure - ? 'geprüfte Quellen ohne Zählinformation; Shopdaten nicht verfügbar' - : 'geprüfte Quellen, keine passende Zählinformation'; + ? $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_shop_unavailable') + : $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_no_count'); } if ($shopSearchHadSystemFailure) { - return $hasKnowledge ? 'fachlich belegt; Shopdaten nicht verfügbar' : 'Shopdaten nicht verfügbar'; + return $hasKnowledge + ? $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_unavailable_with_knowledge') + : $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_unavailable'); } if ($hasKnowledge && $hasShopResults) { - return 'RAG + Shopdaten'; + return $this->agentRunnerConfig->getProductionUiConfidenceLabel('rag_and_shop'); } if (!$hasKnowledge && $hasShopResults) { - return 'nur Shopdaten'; + return $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_only'); } if ($hasKnowledge && $shopSearchAttempted) { - return 'RAG-Wissen, keine Shop-Treffer'; + return $this->agentRunnerConfig->getProductionUiConfidenceLabel('rag_no_shop_hits'); } if ($hasKnowledge) { - return 'fachlich belegt'; + return $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct'); } if ($isCommerceIntent || $shopSearchAttempted) { - return 'keine belastbaren Daten'; + return $this->agentRunnerConfig->getProductionUiConfidenceLabel('no_reliable_data'); } - return 'noch keine belastbaren Treffer'; + return $this->agentRunnerConfig->getProductionUiConfidenceLabel('no_reliable_hits'); } /** @@ -3137,7 +3142,7 @@ final readonly class AgentRunner } if ($label === $this->plainTextFromHtml($this->agentRunnerConfig->getShopSystemSourceLabel())) { - $label = 'Live-Shopdaten'; + $label = $this->agentRunnerConfig->getProductionUiText('live_shop_source_plain_label'); } if (!in_array($label, $labels, true)) { @@ -3153,27 +3158,27 @@ final readonly class AgentRunner */ private function buildShopProductCardsMessage(array $shopResults, string $query, bool $usedRepair): string { - $maxCards = 5; + $maxCards = max(1, $this->agentRunnerConfig->getProductionUiShopResultsMaxCards()); $visibleResults = array_slice($shopResults, 0, $maxCards); $totalCount = count($shopResults); $query = $this->normalizeOneLine($query); - $summary = $totalCount . ' Shop-Treffer ausgewertet'; + $summary = $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_results_summary'), ['count' => (string) $totalCount]); if ($totalCount > $maxCards) { - $summary .= ' · Top ' . $maxCards . ' angezeigt'; + $summary .= $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_results_top_displayed_suffix'), ['max' => (string) $maxCards]); } if ($usedRepair) { - $summary .= ' · erweiterte Shopsuche genutzt'; + $summary .= $this->agentRunnerConfig->getProductionUiTemplate('shop_results_repair_suffix'); } $html = '
' - . '
Shop-Ergebnisse
' - . '
Shop-Ergebnisse
' + . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_results_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' + . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_results_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
' . htmlspecialchars($summary, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; if ($query !== '') { - $html .= '
Ausgewertete Suchquery' + $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('evaluated_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; } @@ -3195,7 +3200,7 @@ final readonly class AgentRunner private function buildShopProductCard(ShopProductResult $product, string $query): string { - $name = $this->normalizeOneLine($product->name) ?: 'Unbenanntes Produkt'; + $name = $this->normalizeOneLine($product->name) ?: $this->agentRunnerConfig->getProductionUiText('unnamed_product'); $productNumber = $this->normalizeOneLine((string) $product->productNumber); $manufacturer = $this->normalizeOneLine((string) $product->manufacturer); $price = $this->normalizeOneLine((string) $product->price); @@ -3215,16 +3220,16 @@ final readonly class AgentRunner } $html .= '
'; - $html .= '
Artikelnummer
' . htmlspecialchars($productNumber !== '' ? $productNumber : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; - $html .= '
Preis
' . htmlspecialchars($price !== '' ? $price : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; - $html .= '
Verfügbarkeit
' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('product_number_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . htmlspecialchars($productNumber !== '' ? $productNumber : $this->agentRunnerConfig->getProductionUiText('field_not_provided'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('price_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . htmlspecialchars($price !== '' ? $price : $this->agentRunnerConfig->getProductionUiText('field_not_provided'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('availability_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; if ($manufacturer !== '') { - $html .= '
Hersteller
' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + $html .= '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('manufacturer_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; } $html .= '
' - . '
Relevanz' + . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('relevance_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . htmlspecialchars($relevance, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . ''; @@ -3235,9 +3240,9 @@ final readonly class AgentRunner private function formatProductAvailability(?bool $available): string { return match ($available) { - true => 'verfügbar', - false => 'nicht verfügbar', - default => 'Shopstatus nicht übermittelt', + true => $this->agentRunnerConfig->getProductionUiText('availability_yes'), + false => $this->agentRunnerConfig->getProductionUiText('availability_no'), + default => $this->agentRunnerConfig->getProductionUiText('availability_unknown'), }; } @@ -3254,28 +3259,32 @@ final readonly class AgentRunner } if ($matchedQueries !== []) { - return 'Gefunden über: ' . implode(', ', array_slice($matchedQueries, 0, 3)); + return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_matched_queries'), [ + 'queries' => implode(', ', array_slice($matchedQueries, 0, 3)), + ]); } foreach ($product->highlights as $highlight) { $highlight = $this->normalizeOneLine($this->plainTextFromHtml((string) $highlight)); if ($highlight !== '') { - return 'Passender Shop-Hinweis: ' . mb_substr($highlight, 0, 140, 'UTF-8'); + return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_highlight'), [ + 'highlight' => mb_substr($highlight, 0, 140, 'UTF-8'), + ]); } } $matchSource = $this->normalizeOneLine((string) $product->matchSource); if ($matchSource !== '') { - return 'Trefferquelle: ' . $matchSource; + return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_match_source'), ['source' => $matchSource]); } if ($query !== '') { - return 'Passend zur Suchquery: ' . $query; + return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_query'), ['query' => $query]); } - return 'Aus den Live-Shopdaten übernommen'; + return $this->agentRunnerConfig->getProductionUiTemplate('relevance_default'); } private function buildFollowUpActionsMessage(bool $isCommerceIntent, bool $hasShopResults, bool $hasKnowledge): string @@ -3287,14 +3296,11 @@ final readonly class AgentRunner $actions = []; if ($isCommerceIntent || $hasShopResults) { - $actions[] = ['Im Shop suchen', 'Suche die aktuelle Produktauswahl im Shop.']; - $actions[] = ['Nur Zubehör anzeigen', 'Zeige aus der aktuellen Produktauswahl nur Zubehör.']; - $actions[] = ['Nur Geräte anzeigen', 'Zeige aus der aktuellen Produktauswahl nur Geräte.']; - $actions[] = ['Preis anzeigen', 'Zeige mir die Preise der aktuell relevanten Produkte.']; + $actions = array_merge($actions, $this->agentRunnerConfig->getProductionUiFollowUpActions('commerce')); } if ($hasKnowledge || $hasShopResults) { - $actions[] = ['Technische Details anzeigen', 'Zeige technische Details zur aktuellen Antwort.']; + $actions = array_merge($actions, $this->agentRunnerConfig->getProductionUiFollowUpActions('knowledge')); } if ($actions === []) { @@ -3302,11 +3308,16 @@ final readonly class AgentRunner } $html = '
' - . '
Folgeaktionen
' - . '
Was möchtest du als Nächstes tun?
' + . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('followup_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' + . '
' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('followup_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
' . '
'; - foreach ($actions as [$label, $actionPrompt]) { + foreach ($actions as $action) { + $label = (string) ($action['label'] ?? ''); + $actionPrompt = (string) ($action['prompt'] ?? ''); + if ($label === '' || $actionPrompt === '') { + continue; + } $html .= '