fix p55
This commit is contained in:
62
RETRIEX_PATCH_56_SINGLE_GENRE_CONFIG_WIRING_README.md
Normal file
62
RETRIEX_PATCH_56_SINGLE_GENRE_CONFIG_WIRING_README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# RetrieX Patch p56 - Single-Genre Config Wiring
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Dieser Patch macht die in p55 eingefuehrte `config/retriex/genre.yaml` fuer eine erste, risikoarme Auswahl fachlicher Runtime-Parameter zur bevorzugten Quelle.
|
||||||
|
|
||||||
|
Es bleibt strikt bei: eine Installation = ein Genre. Es gibt keine Multi-Genre-Umschaltung, keinen Tenant-Kontext und keinen Request-/Host-Resolver.
|
||||||
|
|
||||||
|
## Was geaendert wurde
|
||||||
|
|
||||||
|
- `GenreConfig` erhaelt typisierte Value-Getter fuer Listen, Maps, Strings, Booleans und Integer unter `configuration_values`.
|
||||||
|
- `DomainVocabularyConfig` liest bevorzugt passende Genre-Werte fuer zentrale Produktrollen, Shop-/Prompt-Views, Search-Repair-Views und relevante Vocabulary-Maps.
|
||||||
|
- `CommerceQueryParserConfig` liest bevorzugt Genre-Werte fuer `known_brands` und `search_token_canonical_map`.
|
||||||
|
- `QueryEnricherConfig` liest bevorzugt Genre-Werte fuer Query-Enrichment-Regeln.
|
||||||
|
- `CommerceIntentConfig` liest bevorzugt Genre-Werte fuer Commerce-Signale, Advisory-Patterns, Farben und Groessen.
|
||||||
|
- `AgentRunnerConfig` liest bevorzugt Genre-Werte fuer ausgewaehlte Shop-Runtime-Listen, Kontextanker, Direct-Answer-Texte und Laengen-Constraint-Patterns.
|
||||||
|
- `config/services.yaml` verdrahtet `GenreConfig` in diese Config-Fassaden.
|
||||||
|
|
||||||
|
## Nicht geaendert
|
||||||
|
|
||||||
|
- Keine neue Profil-/Layer-Architektur.
|
||||||
|
- Keine Multi-Domain-/Tenant-Loesung.
|
||||||
|
- Keine neuen fachlichen Listen.
|
||||||
|
- Keine Shopware-Kriterien-, Ranking-, Retrieval- oder LLM-Verhaltensaenderung.
|
||||||
|
- Legacy-YAML-Pfade bleiben als Fallback erhalten.
|
||||||
|
|
||||||
|
## Erwartetes Verhalten
|
||||||
|
|
||||||
|
Da p55 die aktuellen Werte bereits identisch gespiegelt hat, sollte sich das Laufzeitverhalten nicht aendern. Der Unterschied ist nur die bevorzugte Pflegequelle fuer die verdrahteten Parameter.
|
||||||
|
|
||||||
|
## Geaenderte Dateien
|
||||||
|
|
||||||
|
- `config/services.yaml`
|
||||||
|
- `src/Config/AgentRunnerConfig.php`
|
||||||
|
- `src/Config/CommerceIntentConfig.php`
|
||||||
|
- `src/Config/CommerceQueryParserConfig.php`
|
||||||
|
- `src/Config/DomainVocabularyConfig.php`
|
||||||
|
- `src/Config/GenreConfig.php`
|
||||||
|
- `src/Config/QueryEnricherConfig.php`
|
||||||
|
|
||||||
|
## Lokale Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -l src/Config/AgentRunnerConfig.php
|
||||||
|
php -l src/Config/CommerceIntentConfig.php
|
||||||
|
php -l src/Config/CommerceQueryParserConfig.php
|
||||||
|
php -l src/Config/DomainVocabularyConfig.php
|
||||||
|
php -l src/Config/GenreConfig.php
|
||||||
|
php -l src/Config/QueryEnricherConfig.php
|
||||||
|
```
|
||||||
|
|
||||||
|
YAML-Parsing fuer alle Dateien unter `config/retriex/*.yaml`.
|
||||||
|
|
||||||
|
Projektchecks nach dem Einspielen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/console cache:clear
|
||||||
|
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
|
||||||
|
```
|
||||||
@@ -124,6 +124,7 @@ services:
|
|||||||
App\Config\DomainVocabularyConfig:
|
App\Config\DomainVocabularyConfig:
|
||||||
arguments:
|
arguments:
|
||||||
$config: '%retriex.vocabulary.config%'
|
$config: '%retriex.vocabulary.config%'
|
||||||
|
$genreConfig: '@App\Config\GenreConfig'
|
||||||
|
|
||||||
App\Config\ContextServiceConfig:
|
App\Config\ContextServiceConfig:
|
||||||
arguments:
|
arguments:
|
||||||
@@ -142,6 +143,7 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$config: '%retriex.agent.config%'
|
$config: '%retriex.agent.config%'
|
||||||
$vocabulary: '@App\Config\DomainVocabularyConfig'
|
$vocabulary: '@App\Config\DomainVocabularyConfig'
|
||||||
|
$genreConfig: '@App\Config\GenreConfig'
|
||||||
|
|
||||||
App\Config\NdjsonHybridRetrieverConfig:
|
App\Config\NdjsonHybridRetrieverConfig:
|
||||||
arguments:
|
arguments:
|
||||||
@@ -159,6 +161,7 @@ services:
|
|||||||
App\Config\QueryEnricherConfig:
|
App\Config\QueryEnricherConfig:
|
||||||
arguments:
|
arguments:
|
||||||
$config: '%retriex.query_enrichment.config%'
|
$config: '%retriex.query_enrichment.config%'
|
||||||
|
$genreConfig: '@App\Config\GenreConfig'
|
||||||
|
|
||||||
App\Config\GovernanceConfig:
|
App\Config\GovernanceConfig:
|
||||||
arguments:
|
arguments:
|
||||||
@@ -211,11 +214,13 @@ services:
|
|||||||
App\Config\CommerceIntentConfig:
|
App\Config\CommerceIntentConfig:
|
||||||
arguments:
|
arguments:
|
||||||
$config: '%retriex.intent.commerce.config%'
|
$config: '%retriex.intent.commerce.config%'
|
||||||
|
$genreConfig: '@App\Config\GenreConfig'
|
||||||
|
|
||||||
App\Config\CommerceQueryParserConfig:
|
App\Config\CommerceQueryParserConfig:
|
||||||
arguments:
|
arguments:
|
||||||
$config: '%retriex.commerce_query.config%'
|
$config: '%retriex.commerce_query.config%'
|
||||||
$vocabulary: '@App\Config\DomainVocabularyConfig'
|
$vocabulary: '@App\Config\DomainVocabularyConfig'
|
||||||
|
$genreConfig: '@App\Config\GenreConfig'
|
||||||
|
|
||||||
App\Config\CommerceReferenceResolverConfig:
|
App\Config\CommerceReferenceResolverConfig:
|
||||||
arguments:
|
arguments:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ final class AgentRunnerConfig
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly array $config = [],
|
private readonly array $config = [],
|
||||||
private readonly ?DomainVocabularyConfig $vocabulary = null,
|
private readonly ?DomainVocabularyConfig $vocabulary = null,
|
||||||
|
private readonly ?GenreConfig $genreConfig = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +291,27 @@ final class AgentRunnerConfig
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return string[] */
|
||||||
|
private function genreStringList(string $path): array
|
||||||
|
{
|
||||||
|
return $this->genreConfig?->getValueStringList($path) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function genreString(string $path): string
|
||||||
|
{
|
||||||
|
return $this->genreConfig?->getValueString($path) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function genreBool(string $path): ?bool
|
||||||
|
{
|
||||||
|
return $this->genreConfig?->getValueBool($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function genreInt(string $path): ?int
|
||||||
|
{
|
||||||
|
return $this->genreConfig?->getValueInt($path);
|
||||||
|
}
|
||||||
|
|
||||||
private function getRequiredInt(string $key): int
|
private function getRequiredInt(string $key): int
|
||||||
{
|
{
|
||||||
$value = $this->requiredValue($key);
|
$value = $this->requiredValue($key);
|
||||||
@@ -749,37 +771,44 @@ final class AgentRunnerConfig
|
|||||||
|
|
||||||
public function isDirectShopResultAnswerEnabled(): bool
|
public function isDirectShopResultAnswerEnabled(): bool
|
||||||
{
|
{
|
||||||
return $this->getRequiredBool('shop_runtime.direct_answer.enabled');
|
return $this->genreBool('shop_query_runtime.direct_answer.enabled')
|
||||||
|
?? $this->getRequiredBool('shop_runtime.direct_answer.enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDirectShopResultAnswerMaxResults(): int
|
public function getDirectShopResultAnswerMaxResults(): int
|
||||||
{
|
{
|
||||||
return $this->getRequiredInt('shop_runtime.direct_answer.max_results');
|
return $this->genreInt('shop_query_runtime.direct_answer.max_results')
|
||||||
|
?? $this->getRequiredInt('shop_runtime.direct_answer.max_results');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDirectShopResultAnswerIntro(): string
|
public function getDirectShopResultAnswerIntro(): string
|
||||||
{
|
{
|
||||||
return $this->getRequiredString('shop_runtime.direct_answer.intro');
|
return $this->genreString('shop_query_runtime.direct_answer.intro')
|
||||||
|
?: $this->getRequiredString('shop_runtime.direct_answer.intro');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDirectShopResultAnswerNoResultsMessage(): string
|
public function getDirectShopResultAnswerNoResultsMessage(): string
|
||||||
{
|
{
|
||||||
return $this->getRequiredString('shop_runtime.direct_answer.no_results');
|
return $this->genreString('shop_query_runtime.direct_answer.no_results')
|
||||||
|
?: $this->getRequiredString('shop_runtime.direct_answer.no_results');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDirectShopResultAnswerSortedByLengthNote(): string
|
public function getDirectShopResultAnswerSortedByLengthNote(): string
|
||||||
{
|
{
|
||||||
return $this->getRequiredString('shop_runtime.direct_answer.sorted_by_length_note');
|
return $this->genreString('shop_query_runtime.direct_answer.sorted_by_length_note')
|
||||||
|
?: $this->getRequiredString('shop_runtime.direct_answer.sorted_by_length_note');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDirectShopResultAnswerMinLengthFilterNote(): string
|
public function getDirectShopResultAnswerMinLengthFilterNote(): string
|
||||||
{
|
{
|
||||||
return $this->getRequiredString('shop_runtime.direct_answer.min_length_filter_note');
|
return $this->genreString('shop_query_runtime.direct_answer.min_length_filter_note')
|
||||||
|
?: $this->getRequiredString('shop_runtime.direct_answer.min_length_filter_note');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDirectShopResultAnswerMaxLengthFilterNote(): string
|
public function getDirectShopResultAnswerMaxLengthFilterNote(): string
|
||||||
{
|
{
|
||||||
return $this->getRequiredString('shop_runtime.direct_answer.max_length_filter_note');
|
return $this->genreString('shop_query_runtime.direct_answer.max_length_filter_note')
|
||||||
|
?: $this->getRequiredString('shop_runtime.direct_answer.max_length_filter_note');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getNoLlmFallbackMaxShopResults(): int
|
public function getNoLlmFallbackMaxShopResults(): int
|
||||||
@@ -1073,7 +1102,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopQueryContextUsageReferentialTerms(): array
|
public function getShopQueryContextUsageReferentialTerms(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.context_resolution.context_usage.referential_terms');
|
return $this->genreStringList('context_resolution.referential_terms.terms')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.context_resolution.context_usage.referential_terms');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isShopQueryCurrentInputPreservationEnabled(): bool
|
public function isShopQueryCurrentInputPreservationEnabled(): bool
|
||||||
@@ -1129,7 +1159,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopQueryProductAttributeCleanupComparativeConstraintPatterns(): array
|
public function getShopQueryProductAttributeCleanupComparativeConstraintPatterns(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.attribute_cleanup.comparative_constraint_patterns');
|
return $this->genreStringList('product_attributes.direct_attribute_cleanup.comparative_constraint_patterns')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.attribute_cleanup.comparative_constraint_patterns');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isShopQueryStopwordCleanupEnabled(): bool
|
public function isShopQueryStopwordCleanupEnabled(): bool
|
||||||
@@ -1147,7 +1178,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopQueryStopwordCleanupTerms(): array
|
public function getShopQueryStopwordCleanupTerms(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.query_cleanup.stopword_cleanup.terms');
|
return $this->genreStringList('shop_query_runtime.stopword_cleanup.terms')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.query_cleanup.stopword_cleanup.terms');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isDirectShopResultGuardEnabled(): bool
|
public function isDirectShopResultGuardEnabled(): bool
|
||||||
@@ -1170,7 +1202,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getDirectShopResultGuardCompoundPrefixTerms(): array
|
public function getDirectShopResultGuardCompoundPrefixTerms(): array
|
||||||
{
|
{
|
||||||
return $this->getOptionalStringList('shop_runtime.result_identity.compound_prefix_match.terms');
|
return $this->genreStringList('shop_query_runtime.compound_prefix_match.terms')
|
||||||
|
?: $this->getOptionalStringList('shop_runtime.result_identity.compound_prefix_match.terms');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isDirectShopResultGuardPrimaryIdentityRepairEnabled(): bool
|
public function isDirectShopResultGuardPrimaryIdentityRepairEnabled(): bool
|
||||||
@@ -1188,7 +1221,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getDirectShopResultGuardPrimaryIdentityRepairStopTerms(): array
|
public function getDirectShopResultGuardPrimaryIdentityRepairStopTerms(): array
|
||||||
{
|
{
|
||||||
return $this->getOptionalStringList('shop_runtime.result_identity.primary_identity_repair.stop_terms');
|
return $this->genreStringList('shop_query_runtime.primary_identity_repair.stop_terms')
|
||||||
|
?: $this->getOptionalStringList('shop_runtime.result_identity.primary_identity_repair.stop_terms');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isShopResultLengthSortEnabled(): bool
|
public function isShopResultLengthSortEnabled(): bool
|
||||||
@@ -1201,7 +1235,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopResultLengthSortTriggerPatterns(): array
|
public function getShopResultLengthSortTriggerPatterns(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.answer_constraints.length_sort.trigger_patterns');
|
return $this->genreStringList('product_attributes.numeric_length_constraints.length_sort.trigger_patterns')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.answer_constraints.length_sort.trigger_patterns');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1209,7 +1244,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopResultLengthSortValuePatterns(): array
|
public function getShopResultLengthSortValuePatterns(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.answer_constraints.length_sort.value_patterns');
|
return $this->genreStringList('product_attributes.numeric_length_constraints.length_sort.value_patterns')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.answer_constraints.length_sort.value_patterns');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isShopResultLengthFilterEnabled(): bool
|
public function isShopResultLengthFilterEnabled(): bool
|
||||||
@@ -1222,7 +1258,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopResultMinLengthFilterPatterns(): array
|
public function getShopResultMinLengthFilterPatterns(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.answer_constraints.length_filter.min_patterns');
|
return $this->genreStringList('product_attributes.numeric_length_constraints.length_filter.min_patterns')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.answer_constraints.length_filter.min_patterns');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1230,7 +1267,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopResultMaxLengthFilterPatterns(): array
|
public function getShopResultMaxLengthFilterPatterns(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.answer_constraints.length_filter.max_patterns');
|
return $this->genreStringList('product_attributes.numeric_length_constraints.length_filter.max_patterns')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.answer_constraints.length_filter.max_patterns');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getShopPromptIntro(): string
|
public function getShopPromptIntro(): string
|
||||||
@@ -1323,7 +1361,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopQueryMetaOnlyTerms(): array
|
public function getShopQueryMetaOnlyTerms(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.context_resolution.meta_query_guard.meta_only_terms');
|
return $this->genreStringList('context_resolution.meta_query_guard.meta_only_terms')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.context_resolution.meta_query_guard.meta_only_terms');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isShopQueryContextFallbackEnabled(): bool
|
public function isShopQueryContextFallbackEnabled(): bool
|
||||||
@@ -1356,7 +1395,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopQueryContextFallbackFilterTerms(): array
|
public function getShopQueryContextFallbackFilterTerms(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.context_resolution.meta_query_guard.context_fallback_filter_terms');
|
return $this->genreStringList('context_resolution.meta_query_guard.context_fallback_filter_terms')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.context_resolution.meta_query_guard.context_fallback_filter_terms');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isShopQueryContextAnchorEnrichmentEnabled(): bool
|
public function isShopQueryContextAnchorEnrichmentEnabled(): bool
|
||||||
@@ -1385,12 +1425,14 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopQueryContextAnchorEnrichmentPatterns(): array
|
public function getShopQueryContextAnchorEnrichmentPatterns(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.context_resolution.history_anchor_enrichment.anchor_patterns');
|
return $this->genreStringList('context_resolution.history_anchor_enrichment.anchor_patterns')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.context_resolution.history_anchor_enrichment.anchor_patterns');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getShopQueryContextAnchorEnrichmentTemplate(): string
|
public function getShopQueryContextAnchorEnrichmentTemplate(): string
|
||||||
{
|
{
|
||||||
return $this->getRequiredString('shop_runtime.context_resolution.history_anchor_enrichment.template');
|
return $this->genreString('context_resolution.history_anchor_enrichment.template')
|
||||||
|
?: $this->getRequiredString('shop_runtime.context_resolution.history_anchor_enrichment.template');
|
||||||
}
|
}
|
||||||
public function isShopQueryRagAnchorEnrichmentEnabled(): bool
|
public function isShopQueryRagAnchorEnrichmentEnabled(): bool
|
||||||
{
|
{
|
||||||
@@ -1437,7 +1479,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopQueryRagAnchorEnrichmentNumericFocusPatterns(): array
|
public function getShopQueryRagAnchorEnrichmentNumericFocusPatterns(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.context_resolution.rag_anchor_enrichment.numeric_focus_patterns');
|
return $this->genreStringList('context_resolution.rag_anchor_enrichment.numeric_focus_patterns')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.context_resolution.rag_anchor_enrichment.numeric_focus_patterns');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1445,7 +1488,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopQueryRagAnchorEnrichmentProductTitlePatterns(): array
|
public function getShopQueryRagAnchorEnrichmentProductTitlePatterns(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.context_resolution.rag_anchor_enrichment.product_title_patterns');
|
return $this->genreStringList('context_resolution.rag_anchor_enrichment.product_title_patterns')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.context_resolution.rag_anchor_enrichment.product_title_patterns');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1453,7 +1497,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopQueryRagAnchorEnrichmentAnchorBonusPatterns(): array
|
public function getShopQueryRagAnchorEnrichmentAnchorBonusPatterns(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.context_resolution.rag_anchor_enrichment.anchor_bonus_patterns');
|
return $this->genreStringList('context_resolution.rag_anchor_enrichment.anchor_bonus_patterns')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.context_resolution.rag_anchor_enrichment.anchor_bonus_patterns');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1461,7 +1506,8 @@ final class AgentRunnerConfig
|
|||||||
*/
|
*/
|
||||||
public function getShopQueryRagAnchorEnrichmentSubjectTerms(): array
|
public function getShopQueryRagAnchorEnrichmentSubjectTerms(): array
|
||||||
{
|
{
|
||||||
return $this->getRequiredStringList('shop_runtime.context_resolution.rag_anchor_enrichment.subject_terms');
|
return $this->genreStringList('context_resolution.rag_anchor_enrichment.subject_terms')
|
||||||
|
?: $this->getRequiredStringList('shop_runtime.context_resolution.rag_anchor_enrichment.subject_terms');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getShopQueryTranslationReplacements(string $language): array
|
public function getShopQueryTranslationReplacements(string $language): array
|
||||||
|
|||||||
@@ -9,26 +9,31 @@ final class CommerceIntentConfig
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $config
|
* @param array<string, mixed> $config
|
||||||
*/
|
*/
|
||||||
public function __construct(private readonly array $config = [])
|
public function __construct(
|
||||||
{
|
private readonly array $config = [],
|
||||||
|
private readonly ?GenreConfig $genreConfig = null,
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
public function getStrongSignalsList(): array
|
public function getStrongSignalsList(): array
|
||||||
{
|
{
|
||||||
return $this->requiredStringList('strong_signals');
|
return $this->genreStringList('intent_and_routing.commerce_intent.strong_signals')
|
||||||
|
?: $this->requiredStringList('strong_signals');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
public function getAdvisorySignals(): array
|
public function getAdvisorySignals(): array
|
||||||
{
|
{
|
||||||
return $this->requiredStringList('advisory_signals');
|
return $this->genreStringList('intent_and_routing.commerce_intent.advisory_signals')
|
||||||
|
?: $this->requiredStringList('advisory_signals');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
public function getAdvisoryProductSelectionPatterns(): array
|
public function getAdvisoryProductSelectionPatterns(): array
|
||||||
{
|
{
|
||||||
return $this->requiredStringList('advisory_product_selection_patterns');
|
return $this->genreStringList('intent_and_routing.commerce_intent.advisory_product_selection_patterns')
|
||||||
|
?: $this->requiredStringList('advisory_product_selection_patterns');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
@@ -54,6 +59,12 @@ final class CommerceIntentConfig
|
|||||||
return $this->requiredStringList('technical_factual_knowledge.fact_patterns');
|
return $this->requiredStringList('technical_factual_knowledge.fact_patterns');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return string[] */
|
||||||
|
private function genreStringList(string $path): array
|
||||||
|
{
|
||||||
|
return $this->genreConfig?->getValueStringList($path) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
public function getPriceTerms(): array
|
public function getPriceTerms(): array
|
||||||
{
|
{
|
||||||
@@ -68,7 +79,8 @@ final class CommerceIntentConfig
|
|||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
public function getColorTerms(): array
|
public function getColorTerms(): array
|
||||||
{
|
{
|
||||||
return $this->requiredStringList('color_terms');
|
return $this->genreStringList('product_attributes.size_and_color_terms.color_terms')
|
||||||
|
?: $this->requiredStringList('color_terms');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getColorPattern(): string
|
public function getColorPattern(): string
|
||||||
@@ -79,7 +91,8 @@ final class CommerceIntentConfig
|
|||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
public function getSizeTokenTerms(): array
|
public function getSizeTokenTerms(): array
|
||||||
{
|
{
|
||||||
return $this->requiredStringList('size_token_terms');
|
return $this->genreStringList('product_attributes.size_and_color_terms.size_token_terms')
|
||||||
|
?: $this->requiredStringList('size_token_terms');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSizeTokenPattern(): string
|
public function getSizeTokenPattern(): string
|
||||||
@@ -90,7 +103,8 @@ final class CommerceIntentConfig
|
|||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
public function getSizeTerms(): array
|
public function getSizeTerms(): array
|
||||||
{
|
{
|
||||||
return $this->requiredStringList('size_terms');
|
return $this->genreStringList('product_attributes.size_and_color_terms.size_terms')
|
||||||
|
?: $this->requiredStringList('size_terms');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSizePattern(): string
|
public function getSizePattern(): string
|
||||||
@@ -114,7 +128,8 @@ final class CommerceIntentConfig
|
|||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
public function getExplicitCommerceIntentPatterns(): array
|
public function getExplicitCommerceIntentPatterns(): array
|
||||||
{
|
{
|
||||||
return $this->requiredStringList('explicit_commerce_intent_patterns');
|
return $this->genreStringList('intent_and_routing.commerce_intent.explicit_commerce_intent_patterns')
|
||||||
|
?: $this->requiredStringList('explicit_commerce_intent_patterns');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSkuLikePattern(): string
|
public function getSkuLikePattern(): string
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ final class CommerceQueryParserConfig
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly array $config = [],
|
private readonly array $config = [],
|
||||||
private readonly ?DomainVocabularyConfig $vocabulary = null,
|
private readonly ?DomainVocabularyConfig $vocabulary = null,
|
||||||
|
private readonly ?GenreConfig $genreConfig = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +27,8 @@ final class CommerceQueryParserConfig
|
|||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
public function getKnownBrands(): array
|
public function getKnownBrands(): array
|
||||||
{
|
{
|
||||||
return $this->stringList('known_brands');
|
return $this->genreStringList('brands_and_canonical_terms.known_brands.terms')
|
||||||
|
?: $this->stringList('known_brands');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
@@ -61,6 +63,18 @@ final class CommerceQueryParserConfig
|
|||||||
return $this->stringList('search_control_tokens');
|
return $this->stringList('search_control_tokens');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return string[] */
|
||||||
|
private function genreStringList(string $path): array
|
||||||
|
{
|
||||||
|
return $this->genreConfig?->getValueStringList($path) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
private function genreStringMap(string $path): array
|
||||||
|
{
|
||||||
|
return $this->genreConfig?->getValueStringMap($path) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
private function whitespacePreservingStringList(string $path): array
|
private function whitespacePreservingStringList(string $path): array
|
||||||
{
|
{
|
||||||
@@ -99,7 +113,8 @@ final class CommerceQueryParserConfig
|
|||||||
/** @return array<string, string> */
|
/** @return array<string, string> */
|
||||||
public function getSearchTokenCanonicalMap(): array
|
public function getSearchTokenCanonicalMap(): array
|
||||||
{
|
{
|
||||||
return $this->stringMap('search_token_canonical_map');
|
return $this->genreStringMap('brands_and_canonical_terms.canonical_terms.map')
|
||||||
|
?: $this->stringMap('search_token_canonical_map');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,13 +6,57 @@ namespace App\Config;
|
|||||||
|
|
||||||
final class DomainVocabularyConfig
|
final class DomainVocabularyConfig
|
||||||
{
|
{
|
||||||
public function __construct(private readonly array $config = [])
|
private const CLASS_GENRE_VALUE_PATHS = [
|
||||||
{
|
'device' => 'product_roles.primary_product_terms.terms',
|
||||||
|
'accessory' => 'product_roles.accessory_product_terms.terms',
|
||||||
|
'requested_accessory_code_terms' => 'product_roles.requested_accessory_code_terms.terms',
|
||||||
|
'direct_product_attribute_stop_terms' => 'product_attributes.direct_attribute_cleanup.stop_terms',
|
||||||
|
'input_normalization_fuzzy_routing_terms' => 'intent_and_routing.fuzzy_routing_terms.terms',
|
||||||
|
'agent_no_llm_main_device_request_keywords' => 'product_roles.no_llm_fallback_terms.main_device_request_keywords',
|
||||||
|
'agent_no_llm_accessory_product_keywords' => 'product_roles.no_llm_fallback_terms.accessory_product_keywords',
|
||||||
|
'agent_shop_current_input_preservation_terms' => 'shop_query_runtime.current_input_preservation_terms.terms',
|
||||||
|
'agent_shop_context_anchor_trigger_terms' => 'context_resolution.history_anchor_enrichment.trigger_terms',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const VIEW_GENRE_VALUE_PATHS = [
|
||||||
|
'shop.device_query' => 'product_roles.shop_views.device_query_terms',
|
||||||
|
'shop.accessory_query' => 'product_roles.shop_views.accessory_query_terms',
|
||||||
|
'shop.device_product' => 'product_roles.shop_views.device_product_terms',
|
||||||
|
'shop.accessory_product' => 'product_roles.shop_views.accessory_product_terms',
|
||||||
|
'shop.device_focus' => 'product_roles.shop_views.device_focus_terms',
|
||||||
|
'shop.accessory_focus' => 'product_roles.shop_views.accessory_focus_terms',
|
||||||
|
'prompt.main_device_request_keywords' => 'product_roles.prompt_views.main_device_request_keywords',
|
||||||
|
'prompt.accessory_request_keywords' => 'product_roles.prompt_views.accessory_request_keywords',
|
||||||
|
'prompt.main_device_product_keywords' => 'product_roles.prompt_views.main_device_product_keywords',
|
||||||
|
'prompt.accessory_product_keywords' => 'product_roles.prompt_views.accessory_product_keywords',
|
||||||
|
'search_repair.direct_product_type_terms' => 'product_attributes.direct_attribute_cleanup.product_type_terms',
|
||||||
|
'search_repair.direct_product_attribute_stop_terms' => 'product_attributes.direct_attribute_cleanup.stop_terms',
|
||||||
|
'search_repair.requested_accessory_code_terms' => 'product_roles.requested_accessory_code_terms.terms',
|
||||||
|
'agent.rag_evidence_guard.accessory_lookup_guard_terms' => 'result_identity_and_answer_policy.measurement_evidence_guard_terms.accessory_lookup_guard_terms',
|
||||||
|
'agent.rag_evidence_guard.accessory_lookup_passthrough_terms' => 'result_identity_and_answer_policy.measurement_evidence_guard_terms.accessory_lookup_passthrough_terms',
|
||||||
|
'agent.rag_evidence_guard.generic_positive_context_terms' => 'result_identity_and_answer_policy.measurement_evidence_guard_terms.generic_positive_context_terms',
|
||||||
|
'agent.rag_evidence_guard.generic_negative_context_terms' => 'result_identity_and_answer_policy.measurement_evidence_guard_terms.generic_negative_context_terms',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const MAP_GENRE_VALUE_PATHS = [
|
||||||
|
'shop.accessory_focus_variants' => 'brands_and_canonical_terms.accessory_focus_variants.map',
|
||||||
|
'agent.rag_evidence_guard.synonyms' => 'brands_and_canonical_terms.rag_evidence_synonyms.map',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly array $config = [],
|
||||||
|
private readonly ?GenreConfig $genreConfig = null,
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
public function view(string $path, array $fallback = []): array
|
public function view(string $path, array $fallback = []): array
|
||||||
{
|
{
|
||||||
|
$genreTerms = $this->genreStringListForView($path);
|
||||||
|
if ($genreTerms !== []) {
|
||||||
|
return $genreTerms;
|
||||||
|
}
|
||||||
|
|
||||||
$definition = $this->value('views.' . $path, null);
|
$definition = $this->value('views.' . $path, null);
|
||||||
if (!is_array($definition)) {
|
if (!is_array($definition)) {
|
||||||
return $this->uniqueStringList($fallback);
|
return $this->uniqueStringList($fallback);
|
||||||
@@ -35,12 +79,22 @@ final class DomainVocabularyConfig
|
|||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
public function domainClass(string $name): array
|
public function domainClass(string $name): array
|
||||||
{
|
{
|
||||||
|
$genreTerms = $this->genreStringListForClass($name);
|
||||||
|
if ($genreTerms !== []) {
|
||||||
|
return $genreTerms;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->stringList('classes.' . $name, []);
|
return $this->stringList('classes.' . $name, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, string[]> */
|
/** @return array<string, string[]> */
|
||||||
public function map(string $path, array $fallback = []): array
|
public function map(string $path, array $fallback = []): array
|
||||||
{
|
{
|
||||||
|
$genreMap = $this->genreStringListMapForMap($path);
|
||||||
|
if ($genreMap !== []) {
|
||||||
|
return $genreMap;
|
||||||
|
}
|
||||||
|
|
||||||
$value = $this->value('maps.' . $path, null);
|
$value = $this->value('maps.' . $path, null);
|
||||||
if (!is_array($value)) {
|
if (!is_array($value)) {
|
||||||
return $this->uniqueStringListMap($fallback);
|
return $this->uniqueStringListMap($fallback);
|
||||||
@@ -114,6 +168,41 @@ final class DomainVocabularyConfig
|
|||||||
return $this->config;
|
return $this->config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return string[] */
|
||||||
|
private function genreStringListForClass(string $name): array
|
||||||
|
{
|
||||||
|
if ($this->genreConfig === null || !isset(self::CLASS_GENRE_VALUE_PATHS[$name])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->genreConfig->getValueStringList(self::CLASS_GENRE_VALUE_PATHS[$name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] */
|
||||||
|
private function genreStringListForView(string $path): array
|
||||||
|
{
|
||||||
|
if ($this->genreConfig === null || !isset(self::VIEW_GENRE_VALUE_PATHS[$path])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->genreConfig->getValueStringList(self::VIEW_GENRE_VALUE_PATHS[$path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string[]> */
|
||||||
|
private function genreStringListMapForMap(string $path): array
|
||||||
|
{
|
||||||
|
if ($this->genreConfig === null || !isset(self::MAP_GENRE_VALUE_PATHS[$path])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $this->genreConfig->getValueArray(self::MAP_GENRE_VALUE_PATHS[$path]);
|
||||||
|
if ($value === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->stringListMapWithAliases($value);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return string[] */
|
/** @return string[] */
|
||||||
private function stringList(string $path, array $fallback): array
|
private function stringList(string $path, array $fallback): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -64,6 +64,87 @@ final class GenreConfig
|
|||||||
return is_array($groupValues) ? $groupValues : [];
|
return is_array($groupValues) ? $groupValues : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a non-empty string list from the central single-genre value surface.
|
||||||
|
*
|
||||||
|
* The path is relative to `configuration_values`, for example
|
||||||
|
* `product_roles.primary_product_terms.terms`.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function getValueStringList(string $path): array
|
||||||
|
{
|
||||||
|
return $this->uniqueStringList($this->value('configuration_values.' . $path, []));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a non-empty scalar string map from the central single-genre value surface.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getValueStringMap(string $path): array
|
||||||
|
{
|
||||||
|
$value = $this->value('configuration_values.' . $path, []);
|
||||||
|
|
||||||
|
return is_array($value) ? $this->uniqueStringMap($value) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a raw map/list value from the central single-genre value surface.
|
||||||
|
*
|
||||||
|
* @return array<int|string, mixed>
|
||||||
|
*/
|
||||||
|
public function getValueArray(string $path): array
|
||||||
|
{
|
||||||
|
$value = $this->value('configuration_values.' . $path, []);
|
||||||
|
|
||||||
|
return is_array($value) ? $value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getValueString(string $path): string
|
||||||
|
{
|
||||||
|
$value = $this->value('configuration_values.' . $path, null);
|
||||||
|
if (!is_scalar($value)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getValueBool(string $path): ?bool
|
||||||
|
{
|
||||||
|
$value = $this->value('configuration_values.' . $path, null);
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_scalar($value)) {
|
||||||
|
$normalized = strtolower(trim((string) $value));
|
||||||
|
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getValueInt(string $path): ?int
|
||||||
|
{
|
||||||
|
$value = $this->value('configuration_values.' . $path, null);
|
||||||
|
if (is_int($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && preg_match('/^-?\d+$/', trim($value)) === 1) {
|
||||||
|
return (int) trim($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -82,6 +163,47 @@ final class GenreConfig
|
|||||||
return trim((string) $value);
|
return trim((string) $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return string[] */
|
||||||
|
private function uniqueStringList(mixed $value): array
|
||||||
|
{
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($value as $item) {
|
||||||
|
if (!is_scalar($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = trim((string) $item);
|
||||||
|
if ($item !== '' && !in_array($item, $out, true)) {
|
||||||
|
$out[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
private function uniqueStringMap(array $value): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($value as $key => $mappedValue) {
|
||||||
|
if (!is_scalar($key) || !is_scalar($mappedValue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cleanKey = trim((string) $key);
|
||||||
|
$cleanValue = trim((string) $mappedValue);
|
||||||
|
if ($cleanKey !== '' && $cleanValue !== '') {
|
||||||
|
$out[$cleanKey] = $cleanValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
private function value(string $path, mixed $fallback): mixed
|
private function value(string $path, mixed $fallback): mixed
|
||||||
{
|
{
|
||||||
$current = $this->config;
|
$current = $this->config;
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ final readonly class QueryEnricherConfig
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $config
|
* @param array<string, mixed> $config
|
||||||
*/
|
*/
|
||||||
public function __construct(private array $config)
|
public function __construct(
|
||||||
{
|
private array $config,
|
||||||
|
private readonly ?GenreConfig $genreConfig = null,
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,7 +41,10 @@ final readonly class QueryEnricherConfig
|
|||||||
public function getEnrichQueryList(): array
|
public function getEnrichQueryList(): array
|
||||||
{
|
{
|
||||||
$normalized = [];
|
$normalized = [];
|
||||||
|
$rules = $this->genreConfig?->getValueArray('brands_and_canonical_terms.query_enrichment_rules.rules') ?? [];
|
||||||
|
if ($rules === []) {
|
||||||
$rules = $this->requiredArray('rules');
|
$rules = $this->requiredArray('rules');
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($rules as $key => $value) {
|
foreach ($rules as $key => $value) {
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user