This commit is contained in:
team 1
2026-05-06 14:41:37 +02:00
parent ced1431a35
commit e02c885527
5 changed files with 348 additions and 6 deletions

View File

@@ -513,6 +513,33 @@ parameters:
terms: terms:
- puffer - puffer
- kalibrierpuffer - kalibrierpuffer
primary_identity_repair:
enabled: true
min_query_tokens_after_cleanup: 2
# Only used for a retry query when the direct-result guard would
# otherwise suppress all shop results. Keep product words and context
# such as brand/pH/Redox, but remove target-device wording that can
# push Shopware ranking toward devices instead of the requested
# accessory/consumable.
stop_terms:
- messgerät
- messgeraet
- messgeräte
- messgeraete
- messgeräten
- messgeraeten
- gerät
- geraet
- geräte
- geraete
- geräten
- geraeten
- handmessgerät
- handmessgeraet
- handmessgeräte
- handmessgeraete
- messkoffer
- koffer
length_sort: length_sort:
enabled: true enabled: true

View File

@@ -0,0 +1,114 @@
# RetrieX Patch p52 - Direct Product Primary Identity Repair
## Ziel
Korrigiert die p51-Regression bei direkten Shop-Produktlisten, bei der echte Store-API-Treffer intern auf `Shop-Treffer: 0` fallen konnten.
Betroffener Fall:
```text
welche neomeris puffer gibt es für Kalibrierung von pH-Messgeräten
```
Die angezeigte Shopquery bleibt korrekt und soll nicht wieder mit Geräte-/Darstellungslogik vermischt werden:
```text
neomeris puffer kalibrierung ph messgeräten
```
Wenn die Store API mit dieser Suche Treffer liefert, darf RetrieX diese nicht intern durch den direkten Produkt-Guard verlieren.
## Ursache
p51 hat den Direct-Product-Guard bewusst verschärft: Bei direkten Produktlisten sollten nur noch Produkte ausgegeben werden, deren primäre Identität, also Produktname/URL, zur angefragten Produktart passt. Das verhindert Geräte/Koffer als Ersatztreffer für Verbrauchsmaterialien.
Der Guard war aber zu hart, wenn die vorliegende Shop-Trefferliste bereits durch Shopware-Ranking/Repair/Guardrails in Richtung Geräte verschoben war. Dann konnte der Guard alle Treffer entfernen und der Status fiel auf:
```text
Shop-Treffer: 0
```
obwohl die Store API für die Suchquery echte Produktkandidaten liefert.
## Änderung
p52 ergänzt einen kleinen, defensiven Retry nur für diesen Fall:
1. Die normale Shopquery bleibt unverändert.
2. Der p51-Primary-Identity-Guard läuft weiterhin.
3. Nur wenn der Guard aus einer nicht-leeren Shopliste **alles** entfernt, wird eine bereinigte Repair-Query gebaut.
4. Diese Repair-Query entfernt nur konfigurierte Zielgeräte-Wörter wie `Messgerät`, `Gerät`, `Koffer` und behält Produktart/Brand/Kontext wie `neomeris`, `puffer`, `ph`.
5. Die Repair-Ergebnisse laufen erneut durch denselben Primary-Identity-Guard.
6. Wenn danach weiterhin nichts passt, bleibt die saubere Keine-Treffer-Antwort erhalten; es werden keine Geräte als Ersatz ausgegeben.
Beispiel Repair-Query:
```text
neomeris puffer kalibrierung ph
```
statt die Anfrage als Geräte-/Koffer-Liste zu behandeln.
## Wichtig
- Keine Änderung an der angezeigten primären Shopquery.
- Keine Änderung an Shopware-Kriterien.
- Kein Neomeris-/pH-/Redox-Sonderfall im PHP-Core.
- Die entfernten Zielgeräte-Wörter sind YAML-konfiguriert unter `shop_prompt.direct_result_guard.primary_identity_repair.stop_terms`.
- p51 bleibt erhalten: Geräte/Koffer werden nicht als Pufferliste ausgegeben.
- p48, p45/p46 und der Artikelnummer-Fix p47 bleiben unverändert.
## Geänderte Dateien
```text
config/retriex/agent.yaml
src/Agent/AgentRunner.php
src/Config/AgentRunnerConfig.php
patch_history/RETRIEX_PATCH_52_DIRECT_PRODUCT_PRIMARY_IDENTITY_REPAIR_README.md
```
## Erwartetes Verhalten
```text
welche neomeris puffer gibt es für Kalibrierung von pH-Messgeräten
```
soll wieder echte Pufferprodukte ausgeben, sofern sie von der Store API geliefert werden, zum Beispiel:
```text
Neomeris pH-Pufferlösung pH 4.01
Neomeris pH-Pufferlösung pH 7.00
Neomeris pH-Pufferlösung pH 9.21
Neomeris pH-Pufferlösung pH 10.01
Neomeris Redox-Pufferlösung 200 mV / 475 mV / 650 mV
```
Es sollen nicht mehr nur Geräte/Koffer erscheinen, und es soll nicht fälschlich `Shop-Treffer: 0` entstehen, wenn die Store API passende Pufferprodukte liefert.
## Lokale Checks
Ausgeführt:
```bash
php -l src/Agent/AgentRunner.php
php -l src/Config/AgentRunnerConfig.php
python3 - <<'PY'
import yaml
from pathlib import Path
for f in ['config/retriex/agent.yaml', 'config/retriex/vocabulary.yaml']:
yaml.safe_load(Path(f).read_text())
print('YAML ok')
PY
```
`bin/console` wurde lokal nicht ausgeführt, weil `vendor/` im ZIP nicht enthalten ist.
## Nach dem Einspielen prüfen
```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
```

View File

@@ -36,7 +36,7 @@ a {
text-decoration: none; text-decoration: none;
} }
li { li {
margin-bottom: .5rem; margin-bottom: .125rem;
} }
a:hover { a:hover {
color: #FFF; color: #FFF;

View File

@@ -486,12 +486,29 @@ final readonly class AgentRunner
} }
} }
$shopResults = $repairPayload['results']; $unguardedShopResults = $repairPayload['results'];
$shopResults = $this->guardDirectProductShopResults($prompt, $shopSearchQuery, $shopResults); $shopResults = $this->guardDirectProductShopResults($prompt, $shopSearchQuery, $unguardedShopResults);
$directIdentityRepairPayload = $this->repairEmptyDirectProductPrimaryIdentityResults(
prompt: $prompt,
userId: $userId,
commerceIntent: $commerceIntent,
shopSearchQuery: $shopSearchQuery,
unguardedShopResults: $unguardedShopResults,
guardedShopResults: $shopResults
);
if ($directIdentityRepairPayload['results'] !== null) {
$shopResults = $directIdentityRepairPayload['results'];
}
$shopResults = $this->sortShopResultsForLengthRequest($prompt, $shopSearchQuery, $shopResults); $shopResults = $this->sortShopResultsForLengthRequest($prompt, $shopSearchQuery, $shopResults);
$attemptedShopRepair = $repairPayload['attemptedRepair']; $attemptedShopRepair = $repairPayload['attemptedRepair'] || $directIdentityRepairPayload['attemptedRepair'];
$usedShopRepair = $repairPayload['usedRepair']; $usedShopRepair = $repairPayload['usedRepair'] || $directIdentityRepairPayload['usedRepair'];
$shopRepairQueries = $repairPayload['repairQueries']; $shopRepairQueries = array_values(array_unique(array_merge(
$repairPayload['repairQueries'],
$directIdentityRepairPayload['repairQueries']
)));
if (!$primaryShopSearchHadSystemFailure) { if (!$primaryShopSearchHadSystemFailure) {
yield $this->systemMsg( yield $this->systemMsg(
@@ -3081,6 +3098,157 @@ final readonly class AgentRunner
return array_values(array_merge($primaryMatches, $corpusMatches)); return array_values(array_merge($primaryMatches, $corpusMatches));
} }
/**
* @param ShopProductResult[] $unguardedShopResults
* @param ShopProductResult[] $guardedShopResults
* @return array{results: array|null, attemptedRepair: bool, usedRepair: bool, repairQueries: string[]}
*/
private function repairEmptyDirectProductPrimaryIdentityResults(
string $prompt,
string $userId,
string $commerceIntent,
string $shopSearchQuery,
array $unguardedShopResults,
array $guardedShopResults
): array {
$emptyResult = [
'results' => null,
'attemptedRepair' => false,
'usedRepair' => false,
'repairQueries' => [],
];
if (
$guardedShopResults !== []
|| $unguardedShopResults === []
|| !$this->agentRunnerConfig->isDirectShopResultGuardPrimaryIdentityRepairEnabled()
) {
return $emptyResult;
}
$requestedTerms = $this->extractRequestedDirectProductTerms($prompt, $shopSearchQuery);
if ($requestedTerms === []) {
return $emptyResult;
}
$repairQuery = $this->buildDirectProductPrimaryIdentityRepairQuery(
shopSearchQuery: $shopSearchQuery,
requestedTerms: $requestedTerms
);
if ($repairQuery === '' || $this->normalizeShopQueryForComparison($repairQuery) === $this->normalizeShopQueryForComparison($shopSearchQuery)) {
return $emptyResult;
}
$this->agentLogger->info('Direct product primary identity guard retrying with cleaned repair query', [
'userId' => $userId,
'commerceIntent' => $commerceIntent,
'prompt' => $prompt,
'shopSearchQuery' => $shopSearchQuery,
'repairQuery' => $repairQuery,
'unguardedShopResultsCount' => count($unguardedShopResults),
'requestedTerms' => $requestedTerms,
]);
$repairResults = $this->searchShop(
$repairQuery,
$commerceIntent,
$userId,
''
);
if ($this->shopSearchService->hadLastSearchSystemFailure()) {
return [
'results' => null,
'attemptedRepair' => true,
'usedRepair' => false,
'repairQueries' => [$repairQuery],
];
}
$guardedRepairResults = $this->guardDirectProductShopResults($prompt, $repairQuery, $repairResults);
if ($guardedRepairResults === []) {
$this->agentLogger->info('Direct product primary identity repair finished without matching products', [
'userId' => $userId,
'commerceIntent' => $commerceIntent,
'prompt' => $prompt,
'shopSearchQuery' => $shopSearchQuery,
'repairQuery' => $repairQuery,
'repairResultsCount' => count($repairResults),
]);
return [
'results' => null,
'attemptedRepair' => true,
'usedRepair' => false,
'repairQueries' => [$repairQuery],
];
}
return [
'results' => $guardedRepairResults,
'attemptedRepair' => true,
'usedRepair' => true,
'repairQueries' => [$repairQuery],
];
}
/**
* @param string[] $requestedTerms
*/
private function buildDirectProductPrimaryIdentityRepairQuery(string $shopSearchQuery, array $requestedTerms): string
{
$tokens = $this->tokenizeShopQueryCandidate($shopSearchQuery);
if ($tokens === []) {
return '';
}
$stopTokens = [];
foreach ($this->agentRunnerConfig->getDirectShopResultGuardPrimaryIdentityRepairStopTerms() as $term) {
foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
$stopTokens[$token] = true;
}
}
$requestedTokens = [];
foreach ($requestedTerms as $term) {
foreach ($this->tokenizeShopQueryCandidate($term) as $token) {
$requestedTokens[$token] = true;
}
}
$kept = [];
foreach ($tokens as $token) {
if (isset($stopTokens[$token]) && !isset($requestedTokens[$token])) {
continue;
}
if (isset($kept[$token])) {
continue;
}
$kept[$token] = $token;
}
foreach (array_keys($requestedTokens) as $requestedToken) {
if (!isset($kept[$requestedToken])) {
$kept[$requestedToken] = $requestedToken;
}
}
if (count($kept) < max(1, $this->agentRunnerConfig->getDirectShopResultGuardPrimaryIdentityRepairMinQueryTokens())) {
return '';
}
return trim(implode(' ', array_values($kept)));
}
private function normalizeShopQueryForComparison(string $query): string
{
return trim(implode(' ', $this->tokenizeShopQueryCandidate($query)));
}
/** /**
* @param ShopProductResult[] $shopResults * @param ShopProductResult[] $shopResults
* @return ShopProductResult[] * @return ShopProductResult[]

View File

@@ -301,6 +301,21 @@ final class AgentRunnerConfig
throw new \InvalidArgumentException(sprintf('RetrieX agent config key "%s" must be numeric.', $key)); throw new \InvalidArgumentException(sprintf('RetrieX agent config key "%s" must be numeric.', $key));
} }
private function getOptionalInt(string $key, int $default): int
{
$value = $this->optionalValue($key);
if ($value === null) {
return $default;
}
if (is_numeric($value)) {
return (int) $value;
}
throw new \InvalidArgumentException(sprintf('RetrieX agent config key "%s" must be numeric.', $key));
}
private function getRequiredBool(string $key): bool private function getRequiredBool(string $key): bool
{ {
$value = $this->requiredValue($key); $value = $this->requiredValue($key);
@@ -1158,6 +1173,24 @@ final class AgentRunnerConfig
return $this->getOptionalStringList('shop_prompt.direct_result_guard.compound_prefix_match.terms'); return $this->getOptionalStringList('shop_prompt.direct_result_guard.compound_prefix_match.terms');
} }
public function isDirectShopResultGuardPrimaryIdentityRepairEnabled(): bool
{
return $this->getOptionalBool('shop_prompt.direct_result_guard.primary_identity_repair.enabled', true);
}
public function getDirectShopResultGuardPrimaryIdentityRepairMinQueryTokens(): int
{
return $this->getOptionalInt('shop_prompt.direct_result_guard.primary_identity_repair.min_query_tokens_after_cleanup', 2);
}
/**
* @return string[]
*/
public function getDirectShopResultGuardPrimaryIdentityRepairStopTerms(): array
{
return $this->getOptionalStringList('shop_prompt.direct_result_guard.primary_identity_repair.stop_terms');
}
public function isShopResultLengthSortEnabled(): bool public function isShopResultLengthSortEnabled(): bool
{ {
return $this->getRequiredBool('shop_prompt.length_sort.enabled'); return $this->getRequiredBool('shop_prompt.length_sort.enabled');