This commit is contained in:
team 1
2026-05-10 08:55:05 +02:00
parent 7e96af5f1d
commit 96375668b2
9 changed files with 379 additions and 29 deletions

View File

@@ -245,18 +245,30 @@ parameters:
action_type: price_details
hide_when_answer_matches_any:
- '/\bkeine?\s+(?:passende[nrs]?\s+)?(?:produktbezeichnung|shop-?treffer|treffer|produkte?)\b/iu'
- '/\bkeine?\b.{0,80}\b(?:explizit|direkt|passende[nrs]?|exakte[nrs]?)\b.{0,80}\b(?:produktbezeichnung|produktreihe|produktnummer|produkte?|shop-?treffer|treffer)\b/iu'
- '/\b(?:technische\s+eignung\s+nicht\s+sicher\s+belegt|ohne\s+technische[nr]?\s+eignung(?:snachweis)?|keine?\b.{0,80}\btechnische[nr]?\s+eignung)\b/iu'
- '/\b(?:preis|preise|kosten)\b.{0,140}\b(?:nicht\s+(?:explizit\s+)?(?:angegeben|enthalten|verfuegbar|verfügbar|bekannt|ausgewiesen)|nur\s+auf\s+anfrage|auf\s+anfrage|keine?\s+preis(?:angabe|information|e)?)\b/iu'
- '/\b(?:exakte[nrs]?|konkrete[nrs]?|aktuelle[nrs]?)\s+preis\b.{0,140}\b(?:nicht|muesste|müsste|anfrage|abrufen|angefragt)\b/iu'
- label: Nur Zubehör anzeigen
prompt: Suche im Shop nach {shop_query} und zeige daraus nur Zubehör.
action_type: role_filter
target_role: accessory_or_consumable
hide_when_answer_matches_any:
- '/\bkeine?\s+(?:passende[nrs]?\s+)?(?:produktbezeichnung|shop-?treffer|treffer|produkte?)\b/iu'
- '/\bkeine?\b.{0,80}\b(?:explizit|direkt|passende[nrs]?|exakte[nrs]?)\b.{0,80}\b(?:produktbezeichnung|produktreihe|produktnummer|produkte?|shop-?treffer|treffer)\b/iu'
- '/\b(?:technische\s+eignung\s+nicht\s+sicher\s+belegt|ohne\s+technische[nr]?\s+eignung(?:snachweis)?|keine?\b.{0,80}\btechnische[nr]?\s+eignung)\b/iu'
- '/\b(?:preis|preise|kosten)\b.{0,140}\b(?:nicht\s+(?:explizit\s+)?(?:angegeben|enthalten|verfuegbar|verfügbar|bekannt|ausgewiesen)|nur\s+auf\s+anfrage|auf\s+anfrage|keine?\s+preis(?:angabe|information|e)?)\b/iu'
- '/\b(?:einzige[nrs]?\s+(?:direkte[nrs]?\s+)?(?:lösung|loesung|messgerät|messgeraet|option)|nur\s+ein\s+(?:direkte[nrs]?\s+)?(?:messgerät|messgeraet|produkt))\b/iu'
- label: Nur Geräte anzeigen
prompt: Suche im Shop nach {shop_query} und zeige daraus nur Geräte.
action_type: role_filter
target_role: main_device
hide_when_answer_matches_any:
- '/\bkeine?\s+(?:passende[nrs]?\s+)?(?:produktbezeichnung|shop-?treffer|treffer|produkte?)\b/iu'
- '/\bkeine?\b.{0,80}\b(?:explizit|direkt|passende[nrs]?|exakte[nrs]?)\b.{0,80}\b(?:produktbezeichnung|produktreihe|produktnummer|produkte?|shop-?treffer|treffer)\b/iu'
- '/\b(?:technische\s+eignung\s+nicht\s+sicher\s+belegt|ohne\s+technische[nr]?\s+eignung(?:snachweis)?|keine?\b.{0,80}\btechnische[nr]?\s+eignung)\b/iu'
- '/\b(?:preis|preise|kosten)\b.{0,140}\b(?:nicht\s+(?:explizit\s+)?(?:angegeben|enthalten|verfuegbar|verfügbar|bekannt|ausgewiesen)|nur\s+auf\s+anfrage|auf\s+anfrage|keine?\s+preis(?:angabe|information|e)?)\b/iu'
- '/\b(?:einzige[nrs]?\s+(?:direkte[nrs]?\s+)?(?:lösung|loesung|messgerät|messgeraet|option)|nur\s+ein\s+(?:direkte[nrs]?\s+)?(?:messgerät|messgeraet|produkt))\b/iu'
knowledge:
- label: Technische Details anzeigen
prompt: Zeige nur zusätzliche technische Details zu {answer_anchor}.
@@ -264,7 +276,8 @@ parameters:
requires_answer_anchor: true
hide_when_answer_detail_score_at_least: 2
hide_when_answer_matches_any:
- '/\b(?:Grenzwert(?:e)?|Messbereich(?:e)?|Indikator(?:typ)?|Einsatzgebiet(?:e)?|Technische Einordnung)\b/iu'
- '/\b(?:Grenzwert(?:e)?|Messbereich(?:e)?|Messparameter|Indikator(?:typ)?|Einsatzgebiet(?:e)?|Technische Einordnung|Technische Eignung|Produktnummer|Verfügbarkeit)\b/iu'
- '/\b(?:preis|preise|kosten)\b.{0,140}\b(?:nicht\s+(?:explizit\s+)?(?:angegeben|enthalten|verfuegbar|verfügbar|bekannt|ausgewiesen)|nur\s+auf\s+anfrage|auf\s+anfrage|keine?\s+preis(?:angabe|information|e)?)\b/iu'
source_labels:
external_url: Externe URL
rag_knowledge: RAG Wissen

View File

@@ -8,7 +8,7 @@ parameters:
retriex.commerce.sales_channel_access_key: '%env(SHOPWARE_SALES_CHANNEL_ACCESS_KEY)%'
retriex.commerce.search_repair.enabled: true
retriex.commerce.search_repair.max_queries: 1
retriex.commerce.search_repair.max_queries: 2
retriex.commerce.search_repair.min_primary_results_without_repair: 2
# Commerce query parser configuration.

View File

@@ -1437,6 +1437,8 @@ parameters:
- '- If the user asks for the price or availability of a referenced accessory, indicator, reagent, kit, set, or consumable, use commercial fields only from a shop result that clearly matches that accessory identity and code.'
- '- If an accessory, indicator, reagent, kit, set, or consumable code is explicitly requested, do not merge shop variants whose code has an additional suffix, prefix, or variant token unless the user explicitly requested that full variant code.'
- '- For such accessory price follow-ups, do not answer with the price, URL, product number, or availability of the main device or of unrelated reagents; if no matching accessory shop item is present, say that the price is not available in the provided shop data.'
- '- If retrieved knowledge identifies a concrete device/model variant with a suffix or code and live shop data contains the same concrete identity, answer with that specific variant instead of downgrading to the generic base family.'
- '- If the primary shop hit is only a generic base family but extended shop search provides more specific RAG-identified variants, use the specific variants for the product recommendation and keep any generic base-family hit separate.'
prompt_keyword_views:
origin: genre_native
technical_product_keywords:
@@ -1593,11 +1595,11 @@ parameters:
specific_model_candidate_patterns:
- /\b([A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*(?:\s+[A-Za-zÄÖÜäöüß0-9][A-Za-zÄÖÜäöüß0-9®\-]*){0,3}\s+\d{2,5}(?:\s+[A-ZÄÖÜ]{1,8})?)\b/u
patterns:
model_candidate: /\b([A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*(?:\s+[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*){0,2}\s+\d{2,5}[A-Za-z0-9\-]*)\b/u
model_candidate: /\b([A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*(?:\s+[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*){0,2}\s+\d{2,5}[A-Za-z0-9\-]*(?:\s+[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß0-9\-]{1,7})?)\b/u
accessory_candidate_template: /\b((?:{terms})\s+\d{1,5}[A-Za-z0-9\-]*)\b/iu
requested_accessory_code: /\b(?:indikator(?:typ)?|indicator(?:\s*type)?|reagenz|reagent)\s*([A-Za-z]{0,3}\s*\d{1,5}[A-Za-z0-9\-]*)\b/iu
accessory_or_bundle_template: /\b({terms})\b/iu
model_like: /\b[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*(?:\s+[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*){0,2}\s+\d{2,5}[A-Za-z0-9\-]*\b/u
model_like: /\b[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*(?:\s+[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß®\-]*){0,2}\s+\d{2,5}[A-Za-z0-9\-]*(?:\s+[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß0-9\-]{1,7})?\b/u
specificity_boost_template: /\b(?:{terms})\b/iu
contains_digit: /\d/u
whitespace_collapse: /\s+/u

View File

@@ -0,0 +1,46 @@
# RetrieX Patch p75 - Follow-up Price Action Guard
## Ziel
Nach p74 konnte bei Antworten mit Preisangaben weiterhin die Folgeaktion `Preis anzeigen` erscheinen. Beispiel:
```text
Zeige mir die Preise zu testomat resthärte indikator.
```
Die Antwort enthielt bereits konkrete Preise wie `98,20 €`, trotzdem wurde erneut `Preis anzeigen` angeboten. Zusätzlich sollte die Preis-Folgeaktion bei klaren No-Match-/Eignungswarnungen defensiver sein.
## Änderung
- Die Preiserkennung für Follow-up-Actions erkennt nun auch Preisformate wie `98,20 €` zuverlässig.
- Die bisherige Regex hatte bei `€` ein Wortgrenzenproblem und erkannte außerdem `Preise` nicht sicher.
- Shop-Folgeaktionen werden zusätzlich per YAML ausgeblendet, wenn die Antwort klar keinen direkten/expliziten Produkttreffer oder keine gesicherte technische Eignung formuliert. Dadurch entstehen bei No-Value-Antworten keine künstlichen Filter- oder Preis-Actions.
## Warum generisch?
Der Patch enthält keine Testomat-, Resthärte-, Indikator- oder TH-Code-Sonderlogik. Er verbessert nur die generische Action-Sichtbarkeit anhand von Preisformaten und konfigurierbaren No-Value-/No-Match-Formulierungen.
## Erwartete Wirkung
- Wenn eine Antwort bereits Preise wie `83,30 €`, `98,20 €` oder `Preis: 109,20 €` enthält, erscheint `Preis anzeigen` nicht erneut.
- Bei klaren No-Match-/Eignungswarnungen wird keine Preisaktion künstlich angeboten.
- Rollenfilter-Actions bleiben unverändert kontextsensitiv, werden aber bei klaren No-Value-/No-Match-Antworten ebenfalls ausgeblendet.
## Checks
Lokal geprüft:
```bash
php -l src/Agent/AgentRunner.php
python3 YAML parse config/retriex/chat-messages.yaml
php smoke: Preisformate mit Eurozeichen werden erkannt
python3 smoke: zusätzliche YAML-No-Value-Patterns matchen
```
In der Zielumgebung zusätzlich ausführen:
```bash
bin/console mto:agent:config:validate
bin/console mto:agent:regression:test
bin/console mto:agent:config:audit-source --details
```

View File

@@ -0,0 +1,79 @@
# RETRIEX PATCH 76 - Model Variant Shop Repair
## Ziel
Produktnahe Messgeräte-Anfragen mit technisch belegten Modellvarianten dürfen nicht auf eine generische Basisfamilie zurückfallen, wenn RAG-Wissen konkrete Varianten nennt und die Shop-Suche nur eine zu breite Query verwendet.
Beispielklasse:
- User: `ich möchte gern gesamtchlor messen. welche messgerät sollte ich nutzen`
- RAG nennt konkrete Varianten wie `Testomat 2000 CLF` / `Testomat 2000 CLT`
- Shopquery darf daraus nicht nur `Testomat 2000` machen und die Variante verlieren
## Änderungen
### 1. Modellvariantensuffixe in finalen Shopqueries bewahren
`AgentRunner::filterShopQueryToPositiveTokens()` bewahrt nun generisch alphabetische Modell-/Variantensuffixe, wenn sie direkt an ein bereits erlaubtes Modell-/Code-Token anschließen.
Beispiel:
- vorher: `Testomat 2000 CLF` -> `testomat 2000`
- nachher: `Testomat 2000 CLF` -> `testomat 2000 clf`
Das ist generisch und nicht auf CLF/CLT oder Testomat hardcodiert.
### 2. Mehrere Modellvariantensuffixe in CommerceQueryParser erhalten
`CommerceQueryParser::compactShopSearchTokens()` erhält jetzt nicht nur ein einzelnes Suffix nach einer Modellnummer, sondern eine zusammenhängende Suffixkette.
Beispielklasse:
- `Family 2000 ABC DEF` behält beide Varianten-/Suffix-Tokens, sofern sie dem generischen Suffixmuster entsprechen.
### 3. RAG-gefundene konkrete Modellvarianten als Repair-Queries nutzen
`SearchRepairService` baut bei schwachen primären Shop-Treffern generische Repair-Queries aus konkreten Modellvarianten, die im RAG-Kontext gefunden wurden.
Dabei werden bevorzugt spezifische Varianten mit Suffix/Code genutzt, statt nur die Basisfamilie zu suchen.
### 4. Modellkandidaten-Pattern erweitert
`genre.yaml` erkennt Modellkandidaten mit getrenntem Variantensuffix, z. B. generisch:
- `Produkt 2000 ABC`
- `Produkt 2000 ABC DEF`
### 5. Repair-Budget leicht erhöht
`retriex.commerce.search_repair.max_queries` wurde von `1` auf `2` erhöht, damit bei zwei plausiblen Varianten nicht nur die erste Variante abgefragt wird.
## Nicht geändert
- Keine Testomat-Sonderlogik
- Keine CLF-/CLT-Sonderlogik im PHP-Core
- Kein neues Ranking-/Retrieval-Scoring
- Keine Änderung an Shopware Criteria-Struktur
- Keine Änderung an Produktrollenlogik
## Lokale Checks
- `php -l src/Commerce/SearchRepairService.php`
- `php -l src/Agent/AgentRunner.php`
- `php -l src/Commerce/CommerceQueryParser.php`
- `php -l src/Config/AgentRunnerConfig.php`
- `php -l src/Config/SearchRepairConfig.php`
- YAML Parse für `genre.yaml`, `commerce.yaml`, `agent.yaml`, `search_repair.yaml`
- Regex-Smoke: Modellkandidaten-Pattern erkennt `Testomat 2000 CLF` und `Testomat 2000 CLT`
## Empfohlener Regressionstest
```text
ich möchte gern gesamtchlor messen. welche messgerät sollte ich nutzen
```
Erwartung:
- Shop-/Repair-Logik verliert Suffixe wie `CLF`/`CLT` nicht mehr.
- Wenn Shopdaten konkrete Varianten liefern, sollen diese Varianten in der Antwort erscheinen.
- Eine generische Basisfamilie wie `Testomat 2000` darf nicht die konkrete Variante ersetzen.

View File

@@ -0,0 +1,44 @@
# RetrieX Patch p77 - Price Unavailable Action Guard
## Ziel
Nach p76 ist die fachliche Auswahl fuer Gesamtchlor deutlich besser: Bei `ich moechte gern gesamtchlor messen...` wird der konkrete Shop-/RAG-Anker `Testomat 2000 THCL` gehalten. Danach konnte die Follow-up-UI aber nach einer Preisantwort erneut Actions wie `Preis anzeigen`, `Nur Zubehoer anzeigen`, `Nur Geraete anzeigen` oder `Technische Details anzeigen` anbieten, obwohl die Antwort bereits sagte, dass der konkrete Preis nicht explizit angegeben bzw. nur auf Anfrage verfuegbar ist.
## Änderung
- Die Preis-Erkennung fuer Follow-up-Actions erkennt Preisformate wie `7.202,00 €` und `98,20 €` robuster.
- `Preis anzeigen` wird ausgeblendet, wenn die Antwort bereits Preise enthaelt oder klar sagt, dass der Zielpreis nicht angegeben, nicht enthalten, nicht ausgewiesen oder nur auf Anfrage verfuegbar ist.
- Rollenfilter-Actions werden bei No-Match-, No-Eignung- und Preis-nicht-verfuegbar-Antworten ausgeblendet.
- Rollenfilter-Actions werden ebenfalls ausgeblendet, wenn die Antwort klar eine einzige direkte Loesung bzw. ein einziges direktes Messgeraet nennt; dann erzeugen Filter keinen Mehrwert.
- `Technische Details anzeigen` wird ausgeblendet, wenn die Antwort bereits technische Eignung, Messparameter, Produktnummer, Verfuegbarkeit oder eine Preis-nicht-verfuegbar-Einordnung liefert.
## Warum generisch?
Der Patch enthaelt keine Testomat-, THCL-, Gesamtchlor- oder Modell-Sonderlogik. Er erweitert nur die konfigurierbaren Follow-up-Action-Guards und die generische Preisformat-Erkennung.
## Erwartete Wirkung
- Nach einer Preisantwort wie `Der Preis fuer dieses Geraet ist nicht explizit angegeben` erscheint keine erneute `Preis anzeigen`-Action.
- Bei einer klaren Einzelgeraet-Antwort entstehen keine sinnlosen Rollenfilter wie `Nur Zubehoer anzeigen` oder `Nur Geraete anzeigen`.
- Bei Antworten mit technischen Kerndetails erscheint `Technische Details anzeigen` nicht mehr als redundante Aktion.
## Checks
Lokal geprueft:
```bash
php -l src/Agent/AgentRunner.php
php -l src/Config/AgentRunnerConfig.php
php -l src/Config/ChatMessagesConfig.php
python3 YAML parse config/retriex/chat-messages.yaml
php PCRE pattern validation for follow-up action hide patterns
php smoke: price-unavailable pattern matches German no-price wording
```
In der Zielumgebung zusaetzlich ausfuehren:
```bash
bin/console mto:agent:config:validate
bin/console mto:agent:regression:test
bin/console mto:agent:config:audit-source --details
```

View File

@@ -1719,13 +1719,19 @@ final readonly class AgentRunner
return $shopSearchQuery;
}
$modelVariantSuffixTokens = $this->extractPositiveShopQueryModelVariantSuffixTokens($tokens, $blockedTokens, $codePatterns);
$kept = [];
foreach ($tokens as $token) {
if (isset($blockedTokens[$token]) || isset($kept[$token])) {
continue;
}
if (isset($allowedTokens[$token]) || $this->matchesAnyConfiguredShopQueryCodePattern($token, $codePatterns)) {
if (
isset($allowedTokens[$token])
|| isset($modelVariantSuffixTokens[$token])
|| $this->matchesAnyConfiguredShopQueryCodePattern($token, $codePatterns)
) {
$kept[$token] = $token;
}
}
@@ -1807,6 +1813,53 @@ final readonly class AgentRunner
return false;
}
/**
* Preserve model variant suffixes that are attached to an already retained
* model number in the same query, for example family-number-code product
* names. This prevents the positive-token filter from degrading a specific
* model variant to its generic base model.
*
* @param string[] $tokens
* @param array<string, true> $blockedTokens
* @param string[] $codePatterns
* @return array<string, true>
*/
private function extractPositiveShopQueryModelVariantSuffixTokens(
array $tokens,
array $blockedTokens,
array $codePatterns
): array {
$suffixTokens = [];
$count = count($tokens);
for ($index = 0; $index < $count; $index++) {
$token = $tokens[$index] ?? '';
if (!$this->matchesAnyConfiguredShopQueryCodePattern($token, $codePatterns)) {
continue;
}
for ($suffixIndex = $index + 1; $suffixIndex < $count; $suffixIndex++) {
$suffix = $tokens[$suffixIndex] ?? '';
if (isset($blockedTokens[$suffix]) || !$this->isPositiveShopQueryModelVariantSuffixToken($suffix)) {
break;
}
$suffixTokens[$suffix] = true;
}
}
return $suffixTokens;
}
private function isPositiveShopQueryModelVariantSuffixToken(string $token): bool
{
$token = trim($token);
return $token !== ''
&& preg_match('/^[\p{L}]{2,8}\d{0,3}$/u', $token) === 1;
}
private function cleanupDirectProductAttributeShopQuery(string $prompt, string $shopSearchQuery): string
{
$shopSearchQuery = trim($shopSearchQuery);
@@ -3321,10 +3374,6 @@ final readonly class AgentRunner
return $shopResults;
}
if ($this->isMixedDeviceAndAccessoryProductRequest($prompt, $shopSearchQuery)) {
return $shopResults;
}
$primaryMatches = [];
$corpusMatches = [];
@@ -3383,10 +3432,6 @@ final readonly class AgentRunner
return $emptyResult;
}
if ($this->isMixedDeviceAndAccessoryProductRequest($prompt, $shopSearchQuery)) {
return $emptyResult;
}
$repairQuery = $this->buildDirectProductPrimaryIdentityRepairQuery(
shopSearchQuery: $shopSearchQuery,
requestedTerms: $requestedTerms
@@ -4070,7 +4115,7 @@ final readonly class AgentRunner
}
$terms = [];
foreach ($this->agentRunnerConfig->getDirectShopResultProductIdentityTerms() as $term) {
foreach ($this->agentRunnerConfig->getShopQueryProductAttributeCleanupProductTypeTerms() as $term) {
if ($this->containsAllShopQueryTokens($combined, $term)) {
$terms[] = $term;
}
@@ -4079,17 +4124,6 @@ final readonly class AgentRunner
return array_values(array_unique($terms));
}
private function isMixedDeviceAndAccessoryProductRequest(string $prompt, string $shopSearchQuery): bool
{
$combined = mb_strtolower($this->normalizeOneLine($prompt . ' ' . $shopSearchQuery), 'UTF-8');
if ($combined === '') {
return false;
}
return $this->containsAnyConfiguredTerm($combined, $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords())
&& $this->containsAnyConfiguredTerm($combined, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords());
}
private function containsAllShopQueryTokens(string $text, string $term): bool
{
$tokens = array_fill_keys($this->tokenizeShopQueryCandidate($text), true);
@@ -4235,7 +4269,6 @@ final readonly class AgentRunner
|| !$shopSearchAttempted
|| $shopSearchHadSystemFailure
|| $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery) === []
|| $this->isMixedDeviceAndAccessoryProductRequest($prompt, $shopSearchQuery)
) {
return '';
}
@@ -5457,7 +5490,7 @@ final readonly class AgentRunner
private function followUpActionAnswerAlreadyContainsPrice(string $answerText): bool
{
return preg_match('/(?:\bpreis\b.{0,24}\d+[,.]\d{2}|\d+[,.]\d{2}\s*(?:€|eur)\b|(?:€|eur)\s*\d+[,.]\d{2})/iu', $answerText) === 1;
return preg_match('/(?:\bpreise?\b.{0,80}\d+[,.]\d{2}\s*(?:€|eur\b)|\d+[,.]\d{2}\s*(?:€|eur\b)|(?:€|eur\b)\s*\d+[,.]\d{2})/iu', $answerText) === 1;
}
/**

View File

@@ -308,8 +308,11 @@ final readonly class CommerceQueryParser
$keep[$previousIndex] = true;
}
$nextIndex = $index + 1;
if (isset($tokens[$nextIndex]) && $this->isModelSuffixToken($tokens[$nextIndex])) {
for ($nextIndex = $index + 1; isset($tokens[$nextIndex]); $nextIndex++) {
if (!$this->isModelSuffixToken($tokens[$nextIndex])) {
break;
}
$keep[$nextIndex] = true;
}
}

View File

@@ -230,6 +230,21 @@ final readonly class SearchRepairService
}
}
if (
$requestedAccessoryCodes === []
&& $accessoryCandidates === []
) {
$modelVariantQueries = $this->buildSpecificModelVariantRepairQueries(
prompt: $prompt,
primaryQuery: $primaryQuery,
modelCandidates: $modelCandidates
);
if ($modelVariantQueries !== []) {
return $this->normalizeRepairQueries($modelVariantQueries, $primaryQuery);
}
}
$topPrimaryName = $primaryShopResults[0]->name ?? '';
$topPrimaryProductNumber = $primaryShopResults[0]->productNumber ?? null;
$topPrimaryPhrase = trim($topPrimaryName . ' ' . ($topPrimaryProductNumber ?? ''));
@@ -339,6 +354,121 @@ final readonly class SearchRepairService
return $query !== '' ? [$query] : [];
}
/**
* Build repair searches for specific model variants discovered in RAG evidence.
* This keeps suffix variants such as family-number-code product names intact
* instead of falling back to the generic base model.
*
* @param string[] $modelCandidates
* @return string[]
*/
private function buildSpecificModelVariantRepairQueries(
string $prompt,
string $primaryQuery,
array $modelCandidates
): array {
if ($modelCandidates === []) {
return [];
}
$combinedQueryText = trim($prompt . ' ' . $primaryQuery);
$decorated = [];
foreach ($modelCandidates as $index => $candidate) {
$candidate = $this->sanitizeQuery($candidate);
if ($candidate === '' || !$this->isSpecificModelVariantCandidate($candidate)) {
continue;
}
if ($this->queryAlreadyContainsCandidate($combinedQueryText, $candidate)) {
continue;
}
$decorated[] = [
'candidate' => $candidate,
'score' => $this->scoreSpecificModelVariantCandidate($candidate, $combinedQueryText),
'index' => $index,
];
}
if ($decorated === []) {
return [];
}
usort($decorated, static function (array $a, array $b): int {
if ($a['score'] === $b['score']) {
return $a['index'] <=> $b['index'];
}
return $b['score'] <=> $a['score'];
});
return array_values(array_unique(array_map(
static fn(array $row): string => $row['candidate'],
$decorated
)));
}
private function isSpecificModelVariantCandidate(string $candidate): bool
{
return preg_match('/\b\d{2,5}[A-Za-z0-9\-]*\s+[A-Za-zÄÖÜäöüß]{2,8}\d{0,3}(?:\s+[A-Za-zÄÖÜäöüß]{2,8})?\b/u', $candidate) === 1
|| preg_match('/\b\d{2,5}[A-Za-z]{1,8}\d{0,3}\b/u', $candidate) === 1;
}
private function scoreSpecificModelVariantCandidate(string $candidate, string $queryText): int
{
$score = $this->scoreCandidate($candidate);
$suffix = $this->extractModelVariantSuffix($candidate);
if ($suffix !== '') {
$suffixLength = mb_strlen(preg_replace('/\s+/u', '', $suffix) ?? $suffix, 'UTF-8');
$score += min(4, $suffixLength);
$normalizedQuery = $this->normalizeForRepairMatching($queryText);
$normalizedSuffix = $this->normalizeForRepairMatching($suffix);
if ($normalizedSuffix !== '' && preg_match('/\b' . preg_quote($normalizedSuffix, '/') . '\b/u', $normalizedQuery) === 1) {
$score += 12;
}
if (preg_match('/\d/u', $suffix) === 1 && preg_match('/\d/u', $queryText) !== 1) {
$score -= 2;
}
}
return $score;
}
private function extractModelVariantSuffix(string $candidate): string
{
if (preg_match('/\b\d{2,5}[A-Za-z0-9\-]*\s+([A-Za-zÄÖÜäöüß]{2,8}\d{0,3}(?:\s+[A-Za-zÄÖÜäöüß]{2,8})?)\b/u', $candidate, $matches) === 1) {
return $this->sanitizeQuery((string) ($matches[1] ?? ''));
}
if (preg_match('/\b\d{2,5}([A-Za-z]{1,8}\d{0,3})\b/u', $candidate, $matches) === 1) {
return $this->sanitizeQuery((string) ($matches[1] ?? ''));
}
return '';
}
private function queryAlreadyContainsCandidate(string $queryText, string $candidate): bool
{
$queryTokens = array_fill_keys($this->tokenize($queryText), true);
$candidateTokens = $this->tokenize($candidate);
if ($queryTokens === [] || $candidateTokens === []) {
return false;
}
foreach ($candidateTokens as $token) {
if (!isset($queryTokens[$token])) {
return false;
}
}
return true;
}
/** @param string[] $terms */
private function buildTokenSet(array $terms): array
{