This commit is contained in:
team 1
2026-05-04 18:46:26 +02:00
parent 90ced0352a
commit ebd71ba748
15 changed files with 739 additions and 182 deletions

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -16,6 +16,12 @@ parameters:
max_length_ratio_percent: 150 max_length_ratio_percent: 150
heartbeat_message: 'Ich optimiere die Anfrage…' heartbeat_message: 'Ich optimiere die Anfrage…'
output_prefix_pattern: '/^(?:normalisiert|korrigiert|corrected|normalized)\s*:\s*/iu' 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: skip_patterns:
- '/https?:\/\//iu' - '/https?:\/\//iu'
- '/\bwww\./iu' - '/\bwww\./iu'
@@ -188,6 +194,10 @@ parameters:
history_question_pattern: '/^Question:\s*(.+)$/mi' history_question_pattern: '/^Question:\s*(.+)$/mi'
history_turn_split_pattern: '/(?=^Question:\s)/m' history_turn_split_pattern: '/(?=^Question:\s)/m'
history_question_strip_pattern: '/^Question:\s*.*(?:\R|$)/u' 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: 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' 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' hardness_value_pattern: '/\b\d+(?:[,.]\d+)?\s*°\s*dH\b/iu'
@@ -203,6 +213,8 @@ parameters:
analyze_all_information: 'Ich analysiere alle Informationen...' analyze_all_information: 'Ich analysiere alle Informationen...'
thinking_while_streaming: 'Denke nach...' thinking_while_streaming: 'Denke nach...'
no_llm_data_received: '❌ Es wurden keine Daten vom LLM empfangen.' 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.' generic_internal_error: '❌ Bei der Verarbeitung der Anfrage ist ein interner Fehler aufgetreten.'
debug_internal_error_prefix: '❌ Interner Fehler: ' 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.' 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_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.' 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: product_roles:
main_device_request_keywords: main_device_request_keywords:
- anlage - anlage
@@ -354,6 +381,125 @@ parameters:
- 100ml - 100ml
- 500ml - 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: source_labels:
external_url: 'Externe URL' external_url: 'Externe URL'
rag_knowledge: 'RAG Wissen' rag_knowledge: 'RAG Wissen'

View File

@@ -5,7 +5,6 @@ parameters:
words: words:
- mit - mit
- der - der
- dieser
- die - die
- das - das
- ein - ein
@@ -80,11 +79,20 @@ parameters:
ö: oe ö: oe
ü: ue ü: ue
ß: ss ß: ss
word_separator_chars:
- '-'
- '/'
- '_'
dash_equivalents:
- ''
- ''
- ''
- ''
- '—'
stopword_groups: stopword_groups:
de_core: de_core:
- der - der
- dieser
- die - die
- das - das
- den - den
@@ -94,6 +102,7 @@ parameters:
- eine - eine
- einer - einer
- eines - eines
- dieser
- einen - einen
- einem - einem
- und - und

View File

@@ -256,6 +256,29 @@ parameters:
- stoerungsfrei - 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_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}. 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: parameters:
- id: ph - id: ph
label: pH / pH-Wert 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.' - '- 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.' - '- 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.' - '- 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: 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".' - '- 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."' - '- 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.' - '- 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.' - '- 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: response_format:
base_rules: base_rules:
- '- Keep normal spacing between all words. Never fuse words together.' - '- Keep normal spacing between all words. Never fuse words together.'

View File

@@ -83,7 +83,7 @@ final readonly class AgentRunner
yield $this->systemMsg( yield $this->systemMsg(
$this->buildProductionUiMetaMessage( $this->buildProductionUiMetaMessage(
stageLabel: 'Antwort wird vorbereitet', stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('preparing_answer'),
ragCount: null, ragCount: null,
shopCount: null, shopCount: null,
shopCountMode: $this->resolveShopCountModeForMeta( shopCountMode: $this->resolveShopCountModeForMeta(
@@ -93,7 +93,7 @@ final readonly class AgentRunner
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
), ),
sourceLabels: $sources, sourceLabels: $sources,
confidenceLabel: 'Beleglage wird geprüft' confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('checking_evidence')
), ),
'meta' 'meta'
); );
@@ -141,7 +141,7 @@ final readonly class AgentRunner
if ($this->isCommerceIntent($commerceIntent)) { if ($this->isCommerceIntent($commerceIntent)) {
yield $this->systemMsg( yield $this->systemMsg(
$this->buildProductionUiMetaMessage( $this->buildProductionUiMetaMessage(
stageLabel: 'Shop-Routing erkannt', stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_routing_detected'),
ragCount: null, ragCount: null,
shopCount: null, shopCount: null,
shopCountMode: $this->resolveShopCountModeForMeta( shopCountMode: $this->resolveShopCountModeForMeta(
@@ -151,7 +151,7 @@ final readonly class AgentRunner
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
), ),
sourceLabels: $sources, sourceLabels: $sources,
confidenceLabel: 'Shopdaten werden geprüft' confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('checking_shop_data')
), ),
'meta' 'meta'
); );
@@ -174,7 +174,7 @@ final readonly class AgentRunner
yield $this->systemMsg( yield $this->systemMsg(
$this->buildProductionUiMetaMessage( $this->buildProductionUiMetaMessage(
stageLabel: 'RAG-Wissen wurde durchsucht', stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('rag_searched'),
ragCount: count($knowledgeChunks), ragCount: count($knowledgeChunks),
shopCount: null, shopCount: null,
shopCountMode: $this->resolveShopCountModeForMeta( shopCountMode: $this->resolveShopCountModeForMeta(
@@ -202,7 +202,7 @@ final readonly class AgentRunner
if ($this->isCommerceIntent($commerceIntent)) { if ($this->isCommerceIntent($commerceIntent)) {
yield $this->systemMsg( yield $this->systemMsg(
$this->buildProductionUiMetaMessage( $this->buildProductionUiMetaMessage(
stageLabel: 'Shop-Suche wird vorbereitet', stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_search_preparing'),
ragCount: count($knowledgeChunks), ragCount: count($knowledgeChunks),
shopCount: null, shopCount: null,
shopCountMode: 'loading', shopCountMode: 'loading',
@@ -308,7 +308,7 @@ final readonly class AgentRunner
yield $this->systemMsg( yield $this->systemMsg(
$this->buildProductionUiMetaMessage( $this->buildProductionUiMetaMessage(
stageLabel: 'Mehr Kontext nötig', stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('more_context_needed'),
ragCount: count($knowledgeChunks), ragCount: count($knowledgeChunks),
shopCount: null, shopCount: null,
shopCountMode: $this->resolveShopCountModeForMeta( shopCountMode: $this->resolveShopCountModeForMeta(
@@ -318,7 +318,7 @@ final readonly class AgentRunner
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
), ),
sourceLabels: $sources, sourceLabels: $sources,
confidenceLabel: 'mehr Kontext nötig', confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('more_context_needed'),
completed: true completed: true
), ),
'meta' 'meta'
@@ -370,7 +370,7 @@ final readonly class AgentRunner
yield $this->systemMsg( yield $this->systemMsg(
$this->buildProductionUiMetaMessage( $this->buildProductionUiMetaMessage(
stageLabel: 'Shop wird durchsucht', stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('shop_search_running'),
ragCount: count($knowledgeChunks), ragCount: count($knowledgeChunks),
shopCount: null, shopCount: null,
shopCountMode: 'loading', shopCountMode: 'loading',
@@ -420,7 +420,7 @@ final readonly class AgentRunner
'meta' 'meta'
); );
$historyNotices[] = $this->buildHistoryNotice( $historyNotices[] = $this->buildHistoryNotice(
'Shopdaten konnten nicht geladen werden', $this->agentRunnerConfig->getProductionUiText('history_notice_shop_unavailable_title'),
$primaryShopSearchFailureReason $primaryShopSearchFailureReason
); );
@@ -431,7 +431,7 @@ final readonly class AgentRunner
'repairQueries' => [], 'repairQueries' => [],
]; ];
} else { } else {
yield $this->systemMsg('Erweiterte Shopsuche wird geprüft…', 'think'); yield $this->systemMsg($this->agentRunnerConfig->getShopRepairCheckMessage(), 'think');
$repairPayload = $this->repairShopResults( $repairPayload = $this->repairShopResults(
prompt: $prompt, prompt: $prompt,
@@ -476,7 +476,7 @@ final readonly class AgentRunner
yield $this->systemMsg( yield $this->systemMsg(
$this->buildProductionUiMetaMessage( $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), ragCount: count($knowledgeChunks),
shopCount: $primaryShopSearchHadSystemFailure ? null : count($shopResults), shopCount: $primaryShopSearchHadSystemFailure ? null : count($shopResults),
shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : 'count', shopCountMode: $primaryShopSearchHadSystemFailure ? 'unavailable' : 'count',
@@ -542,7 +542,7 @@ final readonly class AgentRunner
yield $this->systemMsg( yield $this->systemMsg(
$this->buildProductionUiMetaMessage( $this->buildProductionUiMetaMessage(
stageLabel: 'Antwort wird generiert', stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('answer_generating'),
ragCount: count($knowledgeChunks), ragCount: count($knowledgeChunks),
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null), shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
shopCountMode: $this->resolveShopCountModeForMeta( shopCountMode: $this->resolveShopCountModeForMeta(
@@ -587,7 +587,7 @@ final readonly class AgentRunner
yield $this->systemMsg( yield $this->systemMsg(
$this->buildProductionUiMetaMessage( $this->buildProductionUiMetaMessage(
stageLabel: 'Abgeschlossen', stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('completed'),
ragCount: count($knowledgeChunks), ragCount: count($knowledgeChunks),
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null), shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
shopCountMode: $this->resolveShopCountModeForMeta( shopCountMode: $this->resolveShopCountModeForMeta(
@@ -667,7 +667,7 @@ final readonly class AgentRunner
$userErrorMessage = $this->buildUserErrorMessage($e); $userErrorMessage = $this->buildUserErrorMessage($e);
yield $this->systemMsg( yield $this->systemMsg(
$this->buildProductionUiMetaMessage( $this->buildProductionUiMetaMessage(
stageLabel: 'Antwort wurde unterbrochen', stageLabel: $this->agentRunnerConfig->getProductionUiStageLabel('interrupted'),
ragCount: count($knowledgeChunks), ragCount: count($knowledgeChunks),
shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null), shopCount: $primaryShopSearchHadSystemFailure ? null : ($shopSearchAttempted ? count($shopResults) : null),
shopCountMode: $this->resolveShopCountModeForMeta( shopCountMode: $this->resolveShopCountModeForMeta(
@@ -677,7 +677,7 @@ final readonly class AgentRunner
shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery shopSearchSkippedBecauseNoQuery: $shopSearchSkippedBecauseNoQuery
), ),
sourceLabels: $sources, sourceLabels: $sources,
confidenceLabel: 'nicht abgeschlossen', confidenceLabel: $this->agentRunnerConfig->getProductionUiConfidenceLabel('interrupted'),
completed: true completed: true
), ),
'meta' 'meta'
@@ -686,7 +686,7 @@ final readonly class AgentRunner
$historyResponse = $this->buildHistoryResponse('', array_merge( $historyResponse = $this->buildHistoryResponse('', array_merge(
$historyNotices, $historyNotices,
[$this->buildHistoryNotice('Antwort konnte nicht abgeschlossen werden', $e->getMessage())] [$this->buildHistoryNotice($this->agentRunnerConfig->getProductionUiText('history_notice_answer_incomplete_title'), $e->getMessage())]
)); ));
if ($historyResponse !== '') { if ($historyResponse !== '') {
@@ -985,12 +985,7 @@ final readonly class AgentRunner
private function normalizeFuzzyRoutingToken(string $token): string private function normalizeFuzzyRoutingToken(string $token): string
{ {
$token = mb_strtolower(trim($token), 'UTF-8'); $token = mb_strtolower(trim($token), 'UTF-8');
$token = strtr($token, [ $token = $this->languageCleanupConfig->transliterateToAscii($token);
'ä' => 'ae',
'ö' => 'oe',
'ü' => 'ue',
'ß' => 'ss',
]);
$token = preg_replace('/[^a-z0-9]+/u', '', $token) ?? $token; $token = preg_replace('/[^a-z0-9]+/u', '', $token) ?? $token;
return trim($token); return trim($token);
@@ -1028,13 +1023,10 @@ final readonly class AgentRunner
{ {
$normalized = $this->normalizeRoutingComparisonText($candidate); $normalized = $this->normalizeRoutingComparisonText($candidate);
return in_array($normalized, [ return in_array($normalized, array_map(
'normalized user input', fn (string $placeholder): string => $this->normalizeRoutingComparisonText($placeholder),
'corrected user input', $this->agentRunnerConfig->getInputNormalizationPlaceholderOutputs()
'user input', ), true);
'normalisierte nutzereingabe',
'korrigierte nutzereingabe',
], true);
} }
private function normalizeRoutingComparisonText(string $value): string private function normalizeRoutingComparisonText(string $value): string
@@ -1132,7 +1124,7 @@ final readonly class AgentRunner
$lines = []; $lines = [];
foreach ($previousQuestions as $question) { foreach ($previousQuestions as $question) {
$lines[] = 'Vorherige Nutzerfrage: ' . $question; $lines[] = $this->renderAgentTemplate($this->agentRunnerConfig->getFollowUpContextPreviousUserQuestionTemplate(), ['question' => $question]);
} }
if ($referenceAnchors !== []) { if ($referenceAnchors !== []) {
@@ -1140,7 +1132,7 @@ final readonly class AgentRunner
. implode(' ', $referenceAnchors); . implode(' ', $referenceAnchors);
} }
$lines[] = 'Aktuelle Folgefrage: ' . $prompt; $lines[] = $this->renderAgentTemplate($this->agentRunnerConfig->getFollowUpContextCurrentQuestionTemplate(), ['question' => $prompt]);
return implode("\n", $lines); return implode("\n", $lines);
} }
@@ -1354,7 +1346,7 @@ final readonly class AgentRunner
private function normalizeFollowUpText(string $value): string private function normalizeFollowUpText(string $value): string
{ {
$value = mb_strtolower(trim($value), 'UTF-8'); $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('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value; $value = preg_replace('/\s+/u', ' ', $value) ?? $value;
@@ -1389,7 +1381,7 @@ final readonly class AgentRunner
} }
if (time() - $lastHeartbeatAt >= 2) { if (time() - $lastHeartbeatAt >= 2) {
yield $this->systemMsg('Shop-Suchanfrage wird optimiert…', 'think'); yield $this->systemMsg($this->agentRunnerConfig->getShopQueryOptimizationHeartbeatMessage(), 'think');
$lastHeartbeatAt = time(); $lastHeartbeatAt = time();
} }
@@ -2027,7 +2019,7 @@ final readonly class AgentRunner
private function tokenizeShopQueryCandidate(string $value): array private function tokenizeShopQueryCandidate(string $value): array
{ {
$value = mb_strtolower(trim($value), 'UTF-8'); $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) { if (preg_match_all('/\d+(?:[,.]\d+)?|[\p{L}\p{N}]+/u', $value, $matches) !== 1) {
return []; return [];
@@ -2077,7 +2069,7 @@ final readonly class AgentRunner
private function tokenizeMetaGuardText(string $value): array private function tokenizeMetaGuardText(string $value): array
{ {
$value = mb_strtolower(trim($value), 'UTF-8'); $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('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value; $value = preg_replace('/\s+/u', ' ', $value) ?? $value;
$value = trim($value); $value = trim($value);
@@ -2232,7 +2224,10 @@ final readonly class AgentRunner
} }
$template = $this->agentRunnerConfig->getShopQueryContextAnchorEnrichmentTemplate(); $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; $enriched = preg_replace('/\s+/u', ' ', $enriched) ?? $enriched;
return trim($enriched) !== '' ? trim($enriched) : $query; return trim($enriched) !== '' ? trim($enriched) : $query;
@@ -2505,7 +2500,10 @@ final readonly class AgentRunner
: $this->agentRunnerConfig->getNoLlmFallbackShopUnavailableNoKnowledgeMessage(); : $this->agentRunnerConfig->getNoLlmFallbackShopUnavailableNoKnowledgeMessage();
if ($reason !== '') { if ($reason !== '') {
$message .= ' Ursache: ' . $reason; $message = $this->renderAgentTemplate($this->agentRunnerConfig->getNoLlmProductField('unavailable_reason_template'), [
'message' => $message,
'reason' => $reason,
]);
} }
return trim($message); return trim($message);
@@ -2542,7 +2540,7 @@ final readonly class AgentRunner
} }
if ($lines === []) { if ($lines === []) {
return ['- Es wurden Shop-Treffer übergeben, aber keine lesbaren Produktdaten gefunden.']; return [$this->agentRunnerConfig->getNoLlmProductField('unreadable_results_message')];
} }
return $lines; return $lines;
@@ -2554,33 +2552,36 @@ final readonly class AgentRunner
$productRole = $this->resolveNoLlmShopProductRole($product); $productRole = $this->resolveNoLlmShopProductRole($product);
$name = $this->normalizeOneLine($product->name); $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) !== '') { 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) !== '') { 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) !== '') { 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) { 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) !== '') { 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') { 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 private function resolveRagEvidenceConfidenceLabel(string $knowledgeEvidenceState): string
{ {
return match ($knowledgeEvidenceState) { return match ($knowledgeEvidenceState) {
'direct' => 'fachlich belegt', 'direct' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct'),
'aggregate_missing' => 'geprüfte Quellen, keine passende Zählinformation', 'aggregate_missing' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing'),
'weak' => 'RAG-Näherungstreffer, kein direkter Fachbeleg', 'weak' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('weak'),
default => 'noch keine belastbaren Treffer', default => $this->agentRunnerConfig->getProductionUiConfidenceLabel('default'),
}; };
} }
private function resolveRagEvidenceShopCheckConfidenceLabel(string $knowledgeEvidenceState): string private function resolveRagEvidenceShopCheckConfidenceLabel(string $knowledgeEvidenceState): string
{ {
return match ($knowledgeEvidenceState) { return match ($knowledgeEvidenceState) {
'direct' => 'fachlich belegt; Shopdaten werden geprüft', 'direct' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct_shop_check'),
'aggregate_missing' => 'geprüfte Quellen ohne Zählinformation; Shopdaten werden geprüft', 'aggregate_missing' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_shop_check'),
'weak' => 'RAG-Näherungstreffer; Shopdaten werden geprüft', 'weak' => $this->agentRunnerConfig->getProductionUiConfidenceLabel('weak_shop_check'),
default => 'Shopdaten werden geprüft', 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 = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$value = mb_strtolower($value, 'UTF-8'); $value = mb_strtolower($value, 'UTF-8');
$value = str_replace(['', '', '', '', '—'], '-', $value); $value = $this->languageCleanupConfig->normalizeDashEquivalents($value);
$value = strtr($value, [ $value = $this->languageCleanupConfig->transliterateToAscii($value);
'ä' => 'ae',
'ö' => 'oe',
'ü' => 'ue',
'ß' => 'ss',
]);
$value = preg_replace('/\s+/u', ' ', $value) ?? $value; $value = preg_replace('/\s+/u', ' ', $value) ?? $value;
return trim($value); return trim($value);
@@ -2957,7 +2953,7 @@ final readonly class AgentRunner
$noLlmMessage = $this->plainTextFromHtml($this->agentRunnerConfig->getNoLlmDataReceivedMessage()); $noLlmMessage = $this->plainTextFromHtml($this->agentRunnerConfig->getNoLlmDataReceivedMessage());
if ($noLlmMessage === '') { if ($noLlmMessage === '') {
$noLlmMessage = 'Es wurden keine Daten vom LLM empfangen.'; $noLlmMessage = $this->agentRunnerConfig->getProductionUiText('no_llm_history_default');
} }
$parts[] = 'Systemhinweis: ' . $noLlmMessage; $parts[] = 'Systemhinweis: ' . $noLlmMessage;
@@ -2972,18 +2968,21 @@ final readonly class AgentRunner
$detail = $this->normalizeOneLine($this->plainTextFromHtml((string) $detail)); $detail = $this->normalizeOneLine($this->plainTextFromHtml((string) $detail));
if ($title === '') { if ($title === '') {
$title = 'Systemhinweis'; $title = $this->agentRunnerConfig->getProductionUiText('history_notice_default_title');
} }
if ($detail === '') { if ($detail === '') {
return 'Systemhinweis: ' . $title . '.'; return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('history_notice_without_detail'), ['title' => $title]);
} }
if (mb_strlen($detail, 'UTF-8') > 500) { if (mb_strlen($detail, 'UTF-8') > 500) {
$detail = rtrim(mb_substr($detail, 0, 497, 'UTF-8')) . '...'; $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 private function plainTextFromHtml(string $value): string
@@ -3033,32 +3032,34 @@ final readonly class AgentRunner
): string { ): string {
$state = $completed ? 'completed' : 'running'; $state = $completed ? 'completed' : 'running';
$ragLabel = $ragCount === null $ragLabel = $ragCount === null
? 'RAG-Treffer: wird geprüft' ? $this->agentRunnerConfig->getProductionUiText('rag_hits_checking')
: 'RAG-Treffer: ' . max(0, $ragCount); : $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('rag_hits_count'), ['count' => (string) max(0, $ragCount)]);
$shopLabel = match ($shopCountMode) { $shopLabel = match ($shopCountMode) {
'count' => 'Shop-Treffer: ' . max(0, (int) $shopCount), 'count' => $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_hits_count'), ['count' => (string) max(0, (int) $shopCount)]),
'loading' => 'Shop-Treffer: wird geladen', 'loading' => $this->agentRunnerConfig->getProductionUiText('shop_hits_loading'),
'unavailable' => 'Shop-Treffer: nicht verfügbar', 'unavailable' => $this->agentRunnerConfig->getProductionUiText('shop_hits_unavailable'),
'not_resolved' => 'Shop-Treffer: keine Suchquery', 'not_resolved' => $this->agentRunnerConfig->getProductionUiText('shop_hits_no_query'),
default => 'Shop-Treffer: nicht angefragt', 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); $sources = $this->formatProductionUiSourceLabels($sourceLabels);
$html = '<div class="retriex-meta-card retriex-run-meta" data-retriex-meta-id="run-status" data-retriex-meta-state="' $html = '<div class="retriex-meta-card retriex-run-meta" data-retriex-meta-id="run-status" data-retriex-meta-state="'
. htmlspecialchars($state, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($state, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '">' . '">'
. '<div class="retriex-meta-card__eyebrow">RetrieX-Status</div>' . '<div class="retriex-meta-card__eyebrow">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('run_status_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-meta-card__title">' . htmlspecialchars($stageLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>' . '<div class="retriex-meta-card__title">' . htmlspecialchars($stageLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-meta-card__body">' . '<div class="retriex-meta-card__body">'
. '<span class="retriex-meta-pill retriex-meta-pill--status">' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>' . '<span class="retriex-meta-pill retriex-meta-pill--status">' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($ragLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>' . '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($ragLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($shopLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>' . '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($shopLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '<span class="retriex-meta-pill retriex-meta-pill--confidence">Beleglage: ' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>' . '<span class="retriex-meta-pill retriex-meta-pill--confidence">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('evidence_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '' . htmlspecialchars($confidenceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '</div>'; . '</div>';
if ($sources !== []) { if ($sources !== []) {
$html .= '<div class="retriex-source-overview"><span>Datenbasis</span><div class="retriex-source-overview__badges">'; $html .= '<div class="retriex-source-overview"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('data_basis_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><div class="retriex-source-overview__badges">';
foreach ($sources as $source) { foreach ($sources as $source) {
$html .= '<span class="retriex-source-chip">' . htmlspecialchars($source, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'; $html .= '<span class="retriex-source-chip">' . htmlspecialchars($source, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';
@@ -3066,8 +3067,10 @@ final readonly class AgentRunner
$html .= '</div></div>'; $html .= '</div></div>';
} else { } else {
$emptySourceLabel = $completed ? 'keine belastbare Datenbasis' : 'wird geprüft'; $emptySourceLabel = $completed
$html .= '<div class="retriex-source-overview"><span>Datenbasis</span><div class="retriex-source-overview__empty">' ? $this->agentRunnerConfig->getProductionUiText('data_basis_empty_completed')
: $this->agentRunnerConfig->getProductionUiText('data_basis_empty_running');
$html .= '<div class="retriex-source-overview"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('data_basis_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><div class="retriex-source-overview__empty">'
. htmlspecialchars($emptySourceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($emptySourceLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</div></div>'; . '</div></div>';
} }
@@ -3087,35 +3090,37 @@ final readonly class AgentRunner
): string { ): string {
if ($knowledgeEvidenceState === 'aggregate_missing' && !$hasShopResults) { if ($knowledgeEvidenceState === 'aggregate_missing' && !$hasShopResults) {
return $shopSearchHadSystemFailure return $shopSearchHadSystemFailure
? 'geprüfte Quellen ohne Zählinformation; Shopdaten nicht verfügbar' ? $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_shop_unavailable')
: 'geprüfte Quellen, keine passende Zählinformation'; : $this->agentRunnerConfig->getProductionUiConfidenceLabel('aggregate_missing_no_count');
} }
if ($shopSearchHadSystemFailure) { 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) { if ($hasKnowledge && $hasShopResults) {
return 'RAG + Shopdaten'; return $this->agentRunnerConfig->getProductionUiConfidenceLabel('rag_and_shop');
} }
if (!$hasKnowledge && $hasShopResults) { if (!$hasKnowledge && $hasShopResults) {
return 'nur Shopdaten'; return $this->agentRunnerConfig->getProductionUiConfidenceLabel('shop_only');
} }
if ($hasKnowledge && $shopSearchAttempted) { if ($hasKnowledge && $shopSearchAttempted) {
return 'RAG-Wissen, keine Shop-Treffer'; return $this->agentRunnerConfig->getProductionUiConfidenceLabel('rag_no_shop_hits');
} }
if ($hasKnowledge) { if ($hasKnowledge) {
return 'fachlich belegt'; return $this->agentRunnerConfig->getProductionUiConfidenceLabel('direct');
} }
if ($isCommerceIntent || $shopSearchAttempted) { 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())) { 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)) { if (!in_array($label, $labels, true)) {
@@ -3153,27 +3158,27 @@ final readonly class AgentRunner
*/ */
private function buildShopProductCardsMessage(array $shopResults, string $query, bool $usedRepair): string private function buildShopProductCardsMessage(array $shopResults, string $query, bool $usedRepair): string
{ {
$maxCards = 5; $maxCards = max(1, $this->agentRunnerConfig->getProductionUiShopResultsMaxCards());
$visibleResults = array_slice($shopResults, 0, $maxCards); $visibleResults = array_slice($shopResults, 0, $maxCards);
$totalCount = count($shopResults); $totalCount = count($shopResults);
$query = $this->normalizeOneLine($query); $query = $this->normalizeOneLine($query);
$summary = $totalCount . ' Shop-Treffer ausgewertet'; $summary = $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_results_summary'), ['count' => (string) $totalCount]);
if ($totalCount > $maxCards) { if ($totalCount > $maxCards) {
$summary .= ' · Top ' . $maxCards . ' angezeigt'; $summary .= $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_results_top_displayed_suffix'), ['max' => (string) $maxCards]);
} }
if ($usedRepair) { if ($usedRepair) {
$summary .= ' · erweiterte Shopsuche genutzt'; $summary .= $this->agentRunnerConfig->getProductionUiTemplate('shop_results_repair_suffix');
} }
$html = '<div class="retriex-meta-card retriex-product-results" data-retriex-meta-id="shop-results" data-retriex-meta-state="completed">' $html = '<div class="retriex-meta-card retriex-product-results" data-retriex-meta-id="shop-results" data-retriex-meta-state="completed">'
. '<div class="retriex-meta-card__eyebrow">Shop-Ergebnisse</div>' . '<div class="retriex-meta-card__eyebrow">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_results_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-meta-card__title">Shop-Ergebnisse</div>' . '<div class="retriex-meta-card__title">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_results_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-product-results__summary">' . htmlspecialchars($summary, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'; . '<div class="retriex-product-results__summary">' . htmlspecialchars($summary, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>';
if ($query !== '') { if ($query !== '') {
$html .= '<div class="retriex-meta-query retriex-meta-query--compact"><span>Ausgewertete Suchquery</span><code>' $html .= '<div class="retriex-meta-query retriex-meta-query--compact"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('evaluated_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><code>'
. htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</code></div>'; . '</code></div>';
} }
@@ -3195,7 +3200,7 @@ final readonly class AgentRunner
private function buildShopProductCard(ShopProductResult $product, string $query): string 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); $productNumber = $this->normalizeOneLine((string) $product->productNumber);
$manufacturer = $this->normalizeOneLine((string) $product->manufacturer); $manufacturer = $this->normalizeOneLine((string) $product->manufacturer);
$price = $this->normalizeOneLine((string) $product->price); $price = $this->normalizeOneLine((string) $product->price);
@@ -3215,16 +3220,16 @@ final readonly class AgentRunner
} }
$html .= '</div><dl class="retriex-product-card__facts">'; $html .= '</div><dl class="retriex-product-card__facts">';
$html .= '<div><dt>Artikelnummer</dt><dd>' . htmlspecialchars($productNumber !== '' ? $productNumber : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>'; $html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('product_number_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($productNumber !== '' ? $productNumber : $this->agentRunnerConfig->getProductionUiText('field_not_provided'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
$html .= '<div><dt>Preis</dt><dd>' . htmlspecialchars($price !== '' ? $price : 'nicht übermittelt', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>'; $html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('price_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($price !== '' ? $price : $this->agentRunnerConfig->getProductionUiText('field_not_provided'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
$html .= '<div><dt>Verfügbarkeit</dt><dd>' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>'; $html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('availability_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($availability, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
if ($manufacturer !== '') { if ($manufacturer !== '') {
$html .= '<div><dt>Hersteller</dt><dd>' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>'; $html .= '<div><dt>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('manufacturer_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dt><dd>' . htmlspecialchars($manufacturer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</dd></div>';
} }
$html .= '</dl>' $html .= '</dl>'
. '<div class="retriex-product-card__relevance"><span>Relevanz</span>' . '<div class="retriex-product-card__relevance"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('relevance_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. htmlspecialchars($relevance, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($relevance, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</div>' . '</div>'
. '</article>'; . '</article>';
@@ -3235,9 +3240,9 @@ final readonly class AgentRunner
private function formatProductAvailability(?bool $available): string private function formatProductAvailability(?bool $available): string
{ {
return match ($available) { return match ($available) {
true => 'verfügbar', true => $this->agentRunnerConfig->getProductionUiText('availability_yes'),
false => 'nicht verfügbar', false => $this->agentRunnerConfig->getProductionUiText('availability_no'),
default => 'Shopstatus nicht übermittelt', default => $this->agentRunnerConfig->getProductionUiText('availability_unknown'),
}; };
} }
@@ -3254,28 +3259,32 @@ final readonly class AgentRunner
} }
if ($matchedQueries !== []) { 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) { foreach ($product->highlights as $highlight) {
$highlight = $this->normalizeOneLine($this->plainTextFromHtml((string) $highlight)); $highlight = $this->normalizeOneLine($this->plainTextFromHtml((string) $highlight));
if ($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); $matchSource = $this->normalizeOneLine((string) $product->matchSource);
if ($matchSource !== '') { if ($matchSource !== '') {
return 'Trefferquelle: ' . $matchSource; return $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('relevance_match_source'), ['source' => $matchSource]);
} }
if ($query !== '') { 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 private function buildFollowUpActionsMessage(bool $isCommerceIntent, bool $hasShopResults, bool $hasKnowledge): string
@@ -3287,14 +3296,11 @@ final readonly class AgentRunner
$actions = []; $actions = [];
if ($isCommerceIntent || $hasShopResults) { if ($isCommerceIntent || $hasShopResults) {
$actions[] = ['Im Shop suchen', 'Suche die aktuelle Produktauswahl im Shop.']; $actions = array_merge($actions, $this->agentRunnerConfig->getProductionUiFollowUpActions('commerce'));
$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.'];
} }
if ($hasKnowledge || $hasShopResults) { if ($hasKnowledge || $hasShopResults) {
$actions[] = ['Technische Details anzeigen', 'Zeige technische Details zur aktuellen Antwort.']; $actions = array_merge($actions, $this->agentRunnerConfig->getProductionUiFollowUpActions('knowledge'));
} }
if ($actions === []) { if ($actions === []) {
@@ -3302,11 +3308,16 @@ final readonly class AgentRunner
} }
$html = '<div class="retriex-meta-card retriex-followup-actions" data-retriex-meta-id="followup-actions" data-retriex-meta-state="completed">' $html = '<div class="retriex-meta-card retriex-followup-actions" data-retriex-meta-id="followup-actions" data-retriex-meta-state="completed">'
. '<div class="retriex-meta-card__eyebrow">Folgeaktionen</div>' . '<div class="retriex-meta-card__eyebrow">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('followup_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-meta-card__title">Was möchtest du als Nächstes tun?</div>' . '<div class="retriex-meta-card__title">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('followup_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-action-chip-row">'; . '<div class="retriex-action-chip-row">';
foreach ($actions as [$label, $actionPrompt]) { foreach ($actions as $action) {
$label = (string) ($action['label'] ?? '');
$actionPrompt = (string) ($action['prompt'] ?? '');
if ($label === '' || $actionPrompt === '') {
continue;
}
$html .= '<button type="button" class="retriex-action-chip" data-retriex-action-prompt="' $html .= '<button type="button" class="retriex-action-chip" data-retriex-action-prompt="'
. htmlspecialchars($actionPrompt, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($actionPrompt, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '">' . '">'
@@ -3334,26 +3345,32 @@ final readonly class AgentRunner
$originalQuery = $this->normalizeOneLine($originalQuery); $originalQuery = $this->normalizeOneLine($originalQuery);
if ($query === '') { if ($query === '') {
$query = $originalQuery !== '' ? $originalQuery : 'keine Suchquery ermittelt'; $query = $originalQuery !== '' ? $originalQuery : $this->agentRunnerConfig->getProductionUiText('shop_meta_fallback_query');
} }
$queryModeLabel = $usedOptimizedQuery ? 'optimiert' : 'direkt'; $queryModeLabel = $usedOptimizedQuery ? $this->agentRunnerConfig->getProductionUiText('shop_meta_query_mode_optimized') : $this->agentRunnerConfig->getProductionUiText('shop_meta_query_mode_direct');
$intentLabel = $commerceIntent !== '' ? $commerceIntent : 'commerce'; $intentLabel = $commerceIntent !== '' ? $commerceIntent : $this->agentRunnerConfig->getProductionUiText('shop_meta_default_intent');
$title = $unavailable ? 'Shopdaten nicht verfügbar' : ($completed ? 'Shop-Suche abgeschlossen' : 'Shop-Suche wird ausgeführt'); $title = $unavailable
$statusLabel = $completed ? 'Status: abgeschlossen' : 'Status: läuft'; ? $this->agentRunnerConfig->getProductionUiText('shop_meta_title_unavailable')
: ($completed
? $this->agentRunnerConfig->getProductionUiText('shop_meta_title_completed')
: $this->agentRunnerConfig->getProductionUiText('shop_meta_title_running'));
$statusLabel = $completed
? $this->agentRunnerConfig->getProductionUiText('shop_meta_status_completed')
: $this->agentRunnerConfig->getProductionUiText('shop_meta_status_running');
$resultLabel = $unavailable $resultLabel = $unavailable
? 'Shoptreffer: nicht verfügbar' ? $this->agentRunnerConfig->getProductionUiText('shop_meta_result_unavailable')
: ($resultCount === null : ($resultCount === null
? 'Shoptreffer: wird geladen' ? $this->agentRunnerConfig->getProductionUiText('shop_meta_result_loading')
: 'Shoptreffer: ' . max(0, $resultCount)); : $this->renderAgentTemplate($this->agentRunnerConfig->getProductionUiTemplate('shop_meta_result_count'), ['count' => (string) max(0, $resultCount)]));
$state = $completed ? 'completed' : 'running'; $state = $completed ? 'completed' : 'running';
$resultCountAttribute = $resultCount === null ? '' : (string) max(0, $resultCount); $resultCountAttribute = $resultCount === null ? '' : (string) max(0, $resultCount);
$repairLabel = ''; $repairLabel = '';
if ($usedRepair) { if ($usedRepair) {
$repairLabel = 'Erweiterte Suche: genutzt'; $repairLabel = $this->agentRunnerConfig->getProductionUiText('shop_meta_repair_used');
} elseif ($attemptedRepair) { } elseif ($attemptedRepair) {
$repairLabel = 'Erweiterte Suche: geprüft'; $repairLabel = $this->agentRunnerConfig->getProductionUiText('shop_meta_repair_checked');
} }
$html = '<div class="retriex-meta-card retriex-shop-meta" data-retriex-meta-id="shop-search" data-retriex-meta-state="' $html = '<div class="retriex-meta-card retriex-shop-meta" data-retriex-meta-id="shop-search" data-retriex-meta-state="'
@@ -3361,20 +3378,20 @@ final readonly class AgentRunner
. '" data-retriex-shop-result-count="' . '" data-retriex-shop-result-count="'
. htmlspecialchars($resultCountAttribute, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($resultCountAttribute, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '">' . '">'
. '<div class="retriex-meta-card__eyebrow">Shop-Suche</div>' . '<div class="retriex-meta-card__eyebrow">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_eyebrow'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-meta-card__title">' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>' . '<div class="retriex-meta-card__title">' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-meta-card__body">' . '<div class="retriex-meta-card__body">'
. '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($resultLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>' . '<span class="retriex-meta-pill retriex-meta-pill--result">' . htmlspecialchars($resultLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '<span class="retriex-meta-pill">' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>' . '<span class="retriex-meta-pill">' . htmlspecialchars($statusLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '<span class="retriex-meta-pill">Query: ' . htmlspecialchars($queryModeLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>' . '<span class="retriex-meta-pill">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($queryModeLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'
. '<span class="retriex-meta-pill">Intent: ' . htmlspecialchars($intentLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'; . '<span class="retriex-meta-pill">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_intent_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($intentLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';
if ($repairLabel !== '') { if ($repairLabel !== '') {
$html .= '<span class="retriex-meta-pill">' . htmlspecialchars($repairLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>'; $html .= '<span class="retriex-meta-pill">' . htmlspecialchars($repairLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';
} }
$html .= '</div>' $html .= '</div>'
. '<div class="retriex-meta-query"><span>Gesendete Suchquery</span><code>' . '<div class="retriex-meta-query"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><code>'
. htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($query, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</code></div>' . '</code></div>'
. '</div>'; . '</div>';
@@ -3387,7 +3404,7 @@ final readonly class AgentRunner
$reason = $this->normalizeOneLine((string) $reason); $reason = $this->normalizeOneLine((string) $reason);
if ($reason === '') { if ($reason === '') {
$reason = 'Keine Detailmeldung vom Shopware-Server.'; $reason = $this->agentRunnerConfig->getProductionUiText('shop_unavailable_default_reason');
} }
if (mb_strlen($reason, 'UTF-8') > 320) { if (mb_strlen($reason, 'UTF-8') > 320) {
@@ -3397,14 +3414,27 @@ final readonly class AgentRunner
return '<div class="retriex-alert retriex-alert--warning">' return '<div class="retriex-alert retriex-alert--warning">'
. '<div class="retriex-alert__icon">⚠️</div>' . '<div class="retriex-alert__icon">⚠️</div>'
. '<div class="retriex-alert__content">' . '<div class="retriex-alert__content">'
. '<div class="retriex-alert__title">Shopdaten konnten nicht geladen werden</div>' . '<div class="retriex-alert__title">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_unavailable_title'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>'
. '<div class="retriex-alert__text">RetrieX antwortet ohne Live-Shopdaten weiter. Ursache: ' . '<div class="retriex-alert__text">' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_unavailable_text_prefix'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''
. htmlspecialchars($reason, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . htmlspecialchars($reason, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</div>' . '</div>'
. '</div>' . '</div>'
. '</div>'; . '</div>';
} }
/**
* @param array<string, string> $values
*/
private function renderAgentTemplate(string $template, array $values): string
{
foreach ($values as $key => $value) {
$template = str_replace('{' . $key . '}', $value, $template);
}
return $template;
}
private function normalizeOneLine(string $value): string private function normalizeOneLine(string $value): string
{ {
$value = trim($value); $value = trim($value);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Agent; namespace App\Agent;
use App\Commerce\Dto\ShopProductResult; use App\Commerce\Dto\ShopProductResult;
use App\Config\LanguageCleanupConfig;
use App\Config\PromptBuilderConfig; use App\Config\PromptBuilderConfig;
use App\Context\ContextService; use App\Context\ContextService;
use App\Repository\SystemPromptRepository; use App\Repository\SystemPromptRepository;
@@ -19,6 +20,7 @@ final readonly class PromptBuilder
private SystemPromptRepository $systemPromptRepository, private SystemPromptRepository $systemPromptRepository,
private ModelGenerationConfigProvider $modelGenerationConfigProvider, private ModelGenerationConfigProvider $modelGenerationConfigProvider,
private PromptBuilderConfig $config, private PromptBuilderConfig $config,
private LanguageCleanupConfig $languageCleanupConfig,
) { ) {
} }
@@ -335,7 +337,7 @@ final readonly class PromptBuilder
} }
if ($hasShopResults && !$commerceSearchAttempted) { if ($hasShopResults && !$commerceSearchAttempted) {
$rules[] = '- Treat shop results as provided context only; do not imply that a live shop check was performed in this run.'; $rules[] = $this->config->getFallbackEscalationProvidedShopResultsContextRule();
} }
if ($rules === []) { if ($rules === []) {
@@ -609,7 +611,7 @@ final readonly class PromptBuilder
$positiveContextTerms = $this->extractMeasurementGuardStringList($guard, 'positive_context_terms'); $positiveContextTerms = $this->extractMeasurementGuardStringList($guard, 'positive_context_terms');
$negativeContextTerms = $this->extractMeasurementGuardStringList($guard, 'negative_context_terms'); $negativeContextTerms = $this->extractMeasurementGuardStringList($guard, 'negative_context_terms');
$nonEquivalentTerms = $this->extractMeasurementGuardStringList($guard, 'non_equivalent_terms'); $nonEquivalentTerms = $this->extractMeasurementGuardStringList($guard, 'non_equivalent_terms');
$label = $this->normalizeBlockText((string) ($guard['label'] ?? 'requested measurement parameter')); $label = $this->normalizeBlockText((string) ($guard['label'] ?? $this->config->getMeasurementEvidenceRuleTemplate('default_requested_parameter_label')));
$strictNoEvidence = (bool) ($guard['strict_no_evidence'] ?? true); $strictNoEvidence = (bool) ($guard['strict_no_evidence'] ?? true);
$resolvedRequestedRole = $requestedRole ?? $this->resolveRequestedProductRole($prompt); $resolvedRequestedRole = $requestedRole ?? $this->resolveRequestedProductRole($prompt);
$safeNoEvidenceAnswer = $this->normalizeBlockText((string) ( $safeNoEvidenceAnswer = $this->normalizeBlockText((string) (
@@ -634,58 +636,59 @@ final readonly class PromptBuilder
if ($hasEvidence) { if ($hasEvidence) {
$shopHasEvidence = true; $shopHasEvidence = true;
$shopEvidenceLines[] = sprintf( $shopEvidenceLines[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('shop_positive_evidence'), [
'- Shop record %d (%s): explicit positive evidence for %s is present in this same record.', 'index' => (string) ($index + 1),
$index + 1, 'product' => $productName !== '' ? $productName : $this->config->getMeasurementEvidenceRuleTemplate('unnamed_product'),
$productName !== '' ? $productName : 'unnamed product', 'label' => $label,
$label ]);
);
} }
} }
if ($shopEvidenceLines === []) { if ($shopEvidenceLines === []) {
$shopEvidenceLines[] = sprintf( $shopEvidenceLines[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('shop_no_evidence'), [
'- No shop product record shown to the model contains explicit positive evidence for %s in the same record.', 'label' => $label,
$label ]);
);
} }
$rules = $this->config->getMeasurementEvidenceIntroRules(); $rules = $this->config->getMeasurementEvidenceIntroRules();
$rules = array_merge($rules, $this->config->getMeasurementEvidenceProductSpecificRules()); $rules = array_merge($rules, $this->config->getMeasurementEvidenceProductSpecificRules());
$rules[] = '- User requested measurement parameter: ' . $label . '.'; $rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('requested_parameter'), ['label' => $label]);
$rules[] = '- Positive parameter terms for this request: ' . implode(', ', $positiveTerms) . '.'; $rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('positive_terms'), ['terms' => implode(', ', $positiveTerms)]);
if ($positiveContextTerms !== []) { if ($positiveContextTerms !== []) {
$rules[] = '- These parameter terms count as suitability evidence only in a measurement-purpose context such as: ' . implode(', ', $positiveContextTerms) . '.'; $rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('positive_context_terms'), ['terms' => implode(', ', $positiveContextTerms)]);
} }
if ($negativeContextTerms !== []) { if ($negativeContextTerms !== []) {
$rules[] = '- These contexts are not suitability evidence by themselves: ' . implode(', ', $negativeContextTerms) . '.'; $rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('negative_context_terms'), ['terms' => implode(', ', $negativeContextTerms)]);
} }
if ($nonEquivalentTerms !== []) { if ($nonEquivalentTerms !== []) {
$rules[] = '- Terms that must NOT be treated as equivalent positive evidence: ' . implode(', ', $nonEquivalentTerms) . '.'; $rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('non_equivalent_terms'), ['terms' => implode(', ', $nonEquivalentTerms)]);
} }
$rules[] = '- RAG/URL evidence scan for this exact parameter: ' . ($knowledgeHasEvidence ? 'explicit positive evidence found.' : 'no explicit positive evidence found.'); $rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('rag_url_evidence_scan'), [
'state' => $knowledgeHasEvidence
? $this->config->getMeasurementEvidenceRuleTemplate('rag_url_evidence_found')
: $this->config->getMeasurementEvidenceRuleTemplate('rag_url_evidence_missing'),
]);
$rules = array_merge($rules, $shopEvidenceLines); $rules = array_merge($rules, $shopEvidenceLines);
if (!$strictNoEvidence && !$knowledgeHasEvidence && !$shopHasEvidence) { if (!$strictNoEvidence && !$knowledgeHasEvidence && !$shopHasEvidence) {
$rules[] = '- 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.'; $rules[] = $this->config->getMeasurementEvidenceRuleTemplate('deterministic_scan_no_product_specific_evidence');
} }
if ($strictNoEvidence && !$knowledgeHasEvidence && !$shopHasEvidence) { if ($strictNoEvidence && !$knowledgeHasEvidence && !$shopHasEvidence) {
$rules[] = '- Mandatory answer behavior: do not recommend a product as suitable for this measurement parameter.'; $rules[] = $this->config->getMeasurementEvidenceRuleTemplate('mandatory_no_recommendation');
if ($safeNoEvidenceAnswer !== '') { if ($safeNoEvidenceAnswer !== '') {
$rules[] = '- Start the answer with this meaning in the user language: ' . $safeNoEvidenceAnswer; $rules[] = $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('start_answer_meaning'), ['answer' => $safeNoEvidenceAnswer]);
} }
if ($resolvedRequestedRole === 'accessory_or_consumable') { if ($resolvedRequestedRole === 'accessory_or_consumable') {
$rules[] = '- 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.'; $rules[] = $this->config->getMeasurementEvidenceRuleTemplate('accessory_mismatch');
} else { } else {
$rules[] = '- You may list exact shop hits only as commercial/search hits under a heading such as "Shop-Treffer (technische Eignung nicht sicher belegt)".'; $rules[] = $this->config->getMeasurementEvidenceRuleTemplate('commercial_hits_only');
} }
} }
$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.'; $rules = array_merge($rules, $this->config->getMeasurementEvidenceFinalRules());
$rules[] = '- The generated shop search query, search intent, ranking position, and user question are not factual evidence for product suitability.';
return $this->buildRuleBlock( return $this->buildRuleBlock(
$this->config->getMeasurementEvidenceSectionLabel(), $this->config->getMeasurementEvidenceSectionLabel(),
@@ -693,6 +696,19 @@ final readonly class PromptBuilder
); );
} }
/**
* @param array<string, string> $values
*/
private function renderPromptTemplate(string $template, array $values): string
{
foreach ($values as $key => $value) {
$template = str_replace('{' . $key . '}', $value, $template);
}
return $template;
}
private function buildShopMeasurementEvidenceLine(ShopProductResult $product, ?array $guard): string private function buildShopMeasurementEvidenceLine(ShopProductResult $product, ?array $guard): string
{ {
if ($guard === null) { if ($guard === null) {
@@ -702,23 +718,21 @@ final readonly class PromptBuilder
$positiveTerms = $this->extractMeasurementGuardStringList($guard, 'positive_terms'); $positiveTerms = $this->extractMeasurementGuardStringList($guard, 'positive_terms');
$positiveContextTerms = $this->extractMeasurementGuardStringList($guard, 'positive_context_terms'); $positiveContextTerms = $this->extractMeasurementGuardStringList($guard, 'positive_context_terms');
$negativeContextTerms = $this->extractMeasurementGuardStringList($guard, 'negative_context_terms'); $negativeContextTerms = $this->extractMeasurementGuardStringList($guard, 'negative_context_terms');
$label = $this->normalizeBlockText((string) ($guard['label'] ?? 'requested measurement parameter')); $label = $this->normalizeBlockText((string) ($guard['label'] ?? $this->config->getMeasurementEvidenceRuleTemplate('default_requested_parameter_label')));
if ($positiveTerms === []) { if ($positiveTerms === []) {
return ''; return '';
} }
if ($this->shopProductHasMeasurementEvidence($product, $positiveTerms, $positiveContextTerms, $negativeContextTerms)) { if ($this->shopProductHasMeasurementEvidence($product, $positiveTerms, $positiveContextTerms, $negativeContextTerms)) {
return sprintf( return $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('shop_record_positive_evidence_line'), [
'Requested measurement evidence: explicit positive evidence for %s is present in this same SHOP PRODUCT RECORD.', 'label' => $label,
$label ]);
);
} }
return sprintf( return $this->renderPromptTemplate($this->config->getMeasurementEvidenceRuleTemplate('shop_record_no_evidence_line'), [
'Requested measurement evidence: no explicit positive evidence for %s is present in this SHOP PRODUCT RECORD. Do not present this record as technically suitable for that measurement parameter.', 'label' => $label,
$label ]);
);
} }
private function resolveRequestedMeasurementGuard(string $prompt): ?array private function resolveRequestedMeasurementGuard(string $prompt): ?array
@@ -815,11 +829,11 @@ final readonly class PromptBuilder
return; return;
} }
$parts = preg_split('/\s*(?:,|;|\/|\boder\b|\bund\b|\bor\b|\band\b)\s*/iu', $value) ?: [$value]; $parts = preg_split($this->config->getParameterParsingSplitPattern(), $value) ?: [$value];
foreach ($parts as $part) { foreach ($parts as $part) {
$part = $this->normalizeBlockText((string) $part); $part = $this->normalizeBlockText((string) $part);
$part = trim($part, " \t\n\r\0\x0B-–—:()[]{}\"'`“”„"); $part = trim($part, $this->config->getParameterParsingTrimCharacters());
if ($part === '' || preg_match('/[\p{L}\p{N}]/u', $part) !== 1) { if ($part === '' || preg_match('/[\p{L}\p{N}]/u', $part) !== 1) {
continue; continue;
@@ -835,7 +849,7 @@ final readonly class PromptBuilder
private function renderMeasurementEvidenceTemplate(string $template, string $label): string private function renderMeasurementEvidenceTemplate(string $template, string $label): string
{ {
return strtr($template, ['{label}' => $label]); return $this->renderPromptTemplate($template, ['label' => $label]);
} }
/** /**
@@ -992,7 +1006,7 @@ final readonly class PromptBuilder
private function normalizeForMeasurementMatching(string $value): string private function normalizeForMeasurementMatching(string $value): string
{ {
$value = mb_strtolower($this->normalizeBlockText($value), 'UTF-8'); $value = mb_strtolower($this->normalizeBlockText($value), 'UTF-8');
$value = str_replace(['', '', '', '', '—'], '-', $value); $value = $this->languageCleanupConfig->normalizeDashEquivalents($value);
$value = preg_replace('/<[^>]+>/u', ' ', $value) ?? $value; $value = preg_replace('/<[^>]+>/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value; $value = preg_replace('/\s+/u', ' ', $value) ?? $value;

View File

@@ -135,6 +135,22 @@ final class AgentRunnerConfig
return $this->getRequiredString('follow_up_context.reference_anchor.hardness_value_pattern'); return $this->getRequiredString('follow_up_context.reference_anchor.hardness_value_pattern');
} }
public function getFollowUpContextPreviousUserQuestionTemplate(): string
{
return $this->getRequiredString('follow_up_context.context_labels.previous_user_question_template');
}
public function getFollowUpContextPreviousReferenceAnchorsTemplate(): string
{
return $this->getRequiredString('follow_up_context.context_labels.previous_reference_anchors_template');
}
public function getFollowUpContextCurrentQuestionTemplate(): string
{
return $this->getRequiredString('follow_up_context.context_labels.current_follow_up_question_template');
}
public function isInputNormalizationEnabled(): bool public function isInputNormalizationEnabled(): bool
{ {
return $this->getRequiredBool('input_normalization.enabled'); return $this->getRequiredBool('input_normalization.enabled');
@@ -170,6 +186,14 @@ final class AgentRunnerConfig
return $this->getRequiredString('input_normalization.output_prefix_pattern'); return $this->getRequiredString('input_normalization.output_prefix_pattern');
} }
/**
* @return string[]
*/
public function getInputNormalizationPlaceholderOutputs(): array
{
return $this->getRequiredStringList('input_normalization.placeholder_outputs');
}
/** /**
* @return string[] * @return string[]
*/ */
@@ -396,6 +420,45 @@ final class AgentRunnerConfig
return $out; return $out;
} }
/**
* @return array<int, array{label:string, prompt:string}>
*/
private function getRequiredActionList(string $key): array
{
$value = $this->requiredValue($key);
if (!is_array($value)) {
throw new \InvalidArgumentException(sprintf('RetrieX agent config key "%s" must be a list of action definitions.', $key));
}
$out = [];
foreach ($value as $item) {
if (!is_array($item)) {
continue;
}
$label = isset($item['label']) && is_scalar($item['label']) ? trim((string) $item['label']) : '';
$prompt = isset($item['prompt']) && is_scalar($item['prompt']) ? trim((string) $item['prompt']) : '';
if ($label === '' || $prompt === '') {
continue;
}
$out[] = [
'label' => $label,
'prompt' => $prompt,
];
}
if ($out === []) {
throw new \InvalidArgumentException(sprintf('RetrieX agent config key "%s" must contain at least one valid action definition.', $key));
}
return $out;
}
/** /**
* @return array<string, string[]> * @return array<string, string[]>
*/ */
@@ -637,6 +700,55 @@ final class AgentRunnerConfig
return $this->getRequiredString('no_llm_fallback.messages.no_data'); return $this->getRequiredString('no_llm_fallback.messages.no_data');
} }
public function getShopRepairCheckMessage(): string
{
return $this->getRequiredString('messages.shop_repair_check');
}
public function getShopQueryOptimizationHeartbeatMessage(): string
{
return $this->getRequiredString('messages.shop_query_optimization_heartbeat');
}
public function getProductionUiStageLabel(string $key): string
{
return $this->getRequiredString('production_ui.stage_labels.' . $key);
}
public function getProductionUiConfidenceLabel(string $key): string
{
return $this->getRequiredString('production_ui.confidence_labels.' . $key);
}
public function getProductionUiText(string $key): string
{
return $this->getRequiredString('production_ui.text.' . $key);
}
public function getProductionUiTemplate(string $key): string
{
return $this->getRequiredString('production_ui.templates.' . $key);
}
public function getProductionUiShopResultsMaxCards(): int
{
return $this->getRequiredInt('production_ui.shop_results.max_cards');
}
/**
* @return array<int, array{label:string, prompt:string}>
*/
public function getProductionUiFollowUpActions(string $group): array
{
return $this->getRequiredActionList('production_ui.follow_up_actions.' . $group);
}
public function getNoLlmProductField(string $key): string
{
return $this->getRequiredString('no_llm_fallback.product_fields.' . $key);
}
public function getNoLlmFallbackNoShopResultsWithKnowledgeMessage(): string public function getNoLlmFallbackNoShopResultsWithKnowledgeMessage(): string
{ {
return $this->getRequiredString('no_llm_fallback.messages.no_shop_results_with_knowledge'); return $this->getRequiredString('no_llm_fallback.messages.no_shop_results_with_knowledge');

View File

@@ -65,6 +65,29 @@ final class LanguageCleanupConfig
return strtr($value, $map); return strtr($value, $map);
} }
/** @return string[] */
public function getWordSeparatorCharacters(): array
{
return $this->getNormalizationStringList('word_separator_chars');
}
/** @return string[] */
public function getDashEquivalents(): array
{
return $this->getNormalizationStringList('dash_equivalents');
}
public function replaceWordSeparatorsWithSpace(string $value): string
{
return str_replace($this->getWordSeparatorCharacters(), ' ', $value);
}
public function normalizeDashEquivalents(string $value): string
{
return str_replace($this->getDashEquivalents(), '-', $value);
}
/** @return string[] */ /** @return string[] */
public function getCleanupProfileNames(): array public function getCleanupProfileNames(): array
{ {

View File

@@ -306,6 +306,11 @@ final class PromptBuilderConfig
return $this->getRequiredString('fallback_escalation.state_line_template'); return $this->getRequiredString('fallback_escalation.state_line_template');
} }
public function getFallbackEscalationProvidedShopResultsContextRule(): string
{
return $this->getRequiredString('fallback_escalation.provided_shop_results_context_rule');
}
/** /**
* @return string[] * @return string[]
*/ */
@@ -623,6 +628,29 @@ final class PromptBuilderConfig
return $this->getRequiredString('measurement_evidence_guard.generic_safe_no_accessory_evidence_answer_template_de'); return $this->getRequiredString('measurement_evidence_guard.generic_safe_no_accessory_evidence_answer_template_de');
} }
public function getMeasurementEvidenceRuleTemplate(string $key): string
{
return $this->getRequiredString('measurement_evidence_guard.rule_templates.' . $key);
}
/**
* @return string[]
*/
public function getMeasurementEvidenceFinalRules(): array
{
return $this->getRequiredStringList('measurement_evidence_guard.final_rules');
}
public function getParameterParsingSplitPattern(): string
{
return $this->getRequiredString('parameter_parsing.split_pattern');
}
public function getParameterParsingTrimCharacters(): string
{
return $this->getRequiredString('parameter_parsing.trim_characters');
}
/** /**
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Knowledge\Retrieval; namespace App\Knowledge\Retrieval;
use App\Config\LanguageCleanupConfig;
use App\Config\NdjsonHybridRetrieverConfig; use App\Config\NdjsonHybridRetrieverConfig;
use App\Knowledge\ChunkManager; use App\Knowledge\ChunkManager;
@@ -11,7 +12,8 @@ final readonly class NdjsonChunkLookup
{ {
public function __construct( public function __construct(
private ChunkManager $chunkManager, private ChunkManager $chunkManager,
private NdjsonHybridRetrieverConfig $retrieverConfig private NdjsonHybridRetrieverConfig $retrieverConfig,
private LanguageCleanupConfig $languageCleanupConfig
) { ) {
} }
@@ -416,7 +418,7 @@ final readonly class NdjsonChunkLookup
private function normalizeText(string $value): string private function normalizeText(string $value): string
{ {
$value = mb_strtolower(trim($value), 'UTF-8'); $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('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value; $value = preg_replace('/\s+/u', ' ', $value) ?? $value;

View File

@@ -1689,7 +1689,7 @@ final readonly class NdjsonHybridRetriever implements RetrieverInterface
private function normalizeText(string $value): string private function normalizeText(string $value): string
{ {
$value = mb_strtolower(trim($value), 'UTF-8'); $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('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value; $value = preg_replace('/\s+/u', ' ', $value) ?? $value;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Knowledge\Retrieval; namespace App\Knowledge\Retrieval;
use App\Config\LanguageCleanupConfig;
use App\Knowledge\StopWords; use App\Knowledge\StopWords;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use SQLite3; use SQLite3;
@@ -18,6 +19,7 @@ final readonly class NdjsonKeywordRetriever
private string $projectDir, private string $projectDir,
private LoggerInterface $agentLogger, private LoggerInterface $agentLogger,
private StopWords $stopWords, private StopWords $stopWords,
private LanguageCleanupConfig $languageCleanupConfig,
) { ) {
} }
@@ -177,7 +179,7 @@ final readonly class NdjsonKeywordRetriever
private function normalizeText(string $value): string private function normalizeText(string $value): string
{ {
$value = mb_strtolower(trim($value), 'UTF-8'); $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('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value; $value = preg_replace('/\s+/u', ' ', $value) ?? $value;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Knowledge\Retrieval; namespace App\Knowledge\Retrieval;
use App\Config\LanguageCleanupConfig;
use App\Knowledge\StopWords; use App\Knowledge\StopWords;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use SQLite3; use SQLite3;
@@ -23,6 +24,7 @@ final readonly class NdjsonLexicalIndexBuilder
private string $projectDir, private string $projectDir,
private LoggerInterface $agentLogger, private LoggerInterface $agentLogger,
private StopWords $stopWords, private StopWords $stopWords,
private LanguageCleanupConfig $languageCleanupConfig,
) { ) {
} }
@@ -350,7 +352,7 @@ final readonly class NdjsonLexicalIndexBuilder
private function normalizeText(string $value): string private function normalizeText(string $value): string
{ {
$value = mb_strtolower(trim($value), 'UTF-8'); $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('/[^\p{L}\p{N}\s]+/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value; $value = preg_replace('/\s+/u', ' ', $value) ?? $value;

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace App\Knowledge\Retrieval; namespace App\Knowledge\Retrieval;
use App\Config\LanguageCleanupConfig;
use App\Knowledge\StopWords; use App\Knowledge\StopWords;
final readonly class QueryCleaner final readonly class QueryCleaner
{ {
public function __construct( public function __construct(
private StopWords $stopWords private StopWords $stopWords,
private LanguageCleanupConfig $languageCleanupConfig
) { ) {
} }
@@ -33,7 +35,7 @@ final readonly class QueryCleaner
$query = mb_strtolower($query, 'UTF-8'); $query = mb_strtolower($query, 'UTF-8');
// 2. Treat hyphens and slashes as word separators // 2. Treat hyphens and slashes as word separators
$query = str_replace(['-', '/'], ' ', $query); $query = $this->languageCleanupConfig->replaceWordSeparatorsWithSpace($query);
// 3. Remove special characters, but keep: // 3. Remove special characters, but keep:
// - letters // - letters