This commit is contained in:
team 1
2026-05-10 18:03:56 +02:00
parent 36485027e6
commit fbd8de64d0
12 changed files with 745 additions and 67 deletions

View File

@@ -49,7 +49,7 @@ parameters:
assistant:
loader: 'Antwort wird vorbereitet…'
aborted: '[aborted]'
history_cleared: 'History cleared.'
history_cleared: 'Chat-History wurde gelöscht.'
source_chips:
live_shop_data: 'Live-Shopdaten'
run_meta:
@@ -209,6 +209,7 @@ parameters:
shop_meta_repair_checked: 'Erweiterte Suche: geprüft'
shop_meta_eyebrow: Shop-Suche
shop_meta_query_label: Gesendete Suchquery
shop_meta_queries_label: Gesendete Einzelqueries
shop_meta_query_prefix: 'Query: '
shop_meta_intent_prefix: 'Intent: '
shop_unavailable_default_reason: Keine Detailmeldung vom Shopware-Server.

View File

@@ -1193,6 +1193,10 @@ parameters:
anchor_patterns:
- '/(?:^|\R)[^\S\r\n]*(?:\d+[.)][^\S\r\n]*)?(?P<anchor>Testomat(?:®)?[^\S\r\n]+\d{3,4}(?:[^\S\r\n]+[A-Za-z0-9-]{1,12}){0,2})\b/iu'
- '/\b(?P<anchor>Testomat(?:®)?[^\S\r\n]+(?:\d{3,4}(?:[^\S\r\n]+(?=[A-Z0-9-]*[A-Z])[A-Z0-9-]{2,12}){0,2}|EVO(?:[^\S\r\n]+[A-Z]{2,8})?|ECO(?:[- ]?(?:PLUS|C))?|DUO(?:[^\S\r\n]+\d{3,4})?|LAB(?:[^\S\r\n]+[A-Z-]{1,8}){1,2}))\b/iu'
canonical_start_patterns:
- '/\b(?P<anchor>Testomat(?:®)?\b.*)$/iu'
canonical_family_terms:
- testomat
meta_query_guard:
origin: genre_native
meta_only_terms:

View File

@@ -0,0 +1,79 @@
# RetrieX Patch p86h - Multi Product Follow-up Split Lookup Completion
## Ziel
p86e/p86g konnten referenzielle Produktlisten-Follow-ups bereits weg von schwachen Meta-Queries wie `links zu aus` auf Produktanker aus dem Verlauf aufloesen. In einem Multi-Produkt-Fall blieb aber eine kombinierte Shopquery sichtbar und teilweise wirksam, z. B.:
```text
testomat 2000 cal 808
```
Diese kombinierte Query kann nur einen Teil der gemeinten Produkte treffen oder ueber Beschreibungen/Zubehoer unpassende Treffer liefern.
## Problem
p86g pruefte zwar die Produktidentitaet, stoppte aber sobald mindestens ein passender Treffer vorhanden war. Dadurch wurden fehlende Verlaufanker nicht separat nachgesucht.
Beispiel:
```text
Vorherige Antwort nennt:
- Testomat 2000 CAL
- Testomat 808
Follow-up:
gebe mir links zu den produkten aus dem shop
Primaere Query:
testomat 2000 cal 808
```
Wenn die primaere Query nur `Testomat 2000 CAL` fand, wurde nicht mehr separat nach `testomat 808` gesucht.
## Loesung
p86h erweitert die p86g-Logik:
1. Bestehende Shop-Treffer werden weiterhin gegen die Produktidentitaeten der Verlaufanker gefiltert.
2. Danach wird ermittelt, welche Verlaufanker bereits abgedeckt sind.
3. Wenn bei mehreren Ankern mindestens einer fehlt, werden die Produktanker separat nachgesucht.
4. Bereits gefundene Identitaetstreffer und separate Anchor-Lookup-Treffer werden dedupliziert gemerged.
5. Die abgeschlossene Shop-Meta-Anzeige zeigt bei referenziellen Produktlisten-Follow-ups die verwendeten Anchor-Queries, z. B.:
```text
testomat 2000 cal | testomat 808
```
## Guardrails
Der Patch ist bewusst eng begrenzt:
- nur referenzielle Produktlisten-/Shoplink-Follow-ups
- keine neue Intent-Erkennung
- keine Aenderung an Retrieval, Ranking, Scoring oder Shop-Matching
- keine harte Produktliste im Core
- keine Sonderlogik fuer medizinische Geraete
- p84 LAB-CL-Kuerzel, p85b SIO2-Anker und Accessory-/Indicator-Flows bleiben unberuehrt
## Geaenderte Dateien
- `src/Agent/AgentRunner.php`
- `patch_history/RETRIEX_PATCH_86H_MULTI_PRODUCT_FOLLOWUP_SPLIT_LOOKUP_README.md`
## Lokale Checks
```text
php -l src/Agent/AgentRunner.php
find src -name '*.php' -print0 | xargs -0 -n1 php -l
YAML parse OK
vendor missing - console checks skipped
```
Console-Checks muessen in einer Umgebung mit `vendor/` ausgefuehrt werden:
```text
php bin/console mto:agent:config:validate
php bin/console mto:agent:regression:test
php bin/console mto:agent:config:audit-source --details
php bin/console mto:agent:config:audit-patterns --details
```

View File

@@ -0,0 +1,68 @@
# RetrieX Patch p86j - Multi Product Split Query Meta Clarity
## Ziel
Referenzielle Produktlisten-Follow-ups wie:
```text
gebe mir links zu den produkten aus dem shop
```
koennen seit p86i mehrere Produktanker als getrennte Shopware-Suchen ausfuehren. Die UI zeigte diese Einzelqueries jedoch weiterhin als zusammengefuehrte Semikolon-Zeile unter `Gesendete Suchquery`, z. B.:
```text
testomat 2000 self clean; testomat 2000 cal; testomat 808
```
Das war missverstaendlich, weil die Shopware-API keine Pipe-/Semikolon-Syntax als einzelne Query versteht.
## Aenderung
- Split-Produktlisten-Follow-ups werden in der Shop-Meta-Card nun als `Gesendete Einzelqueries` dargestellt.
- Jede Einzelquery wird separat gerendert, statt zu einer Semikolon- oder Pipe-Query zusammengefuehrt zu werden.
- Die technische Ausfuehrung aus p86i bleibt erhalten: `searchProductListFollowUpSplitLookupQueries()` fuehrt weiterhin einzelne, begrenzte und sequenzielle `searchShop()`-Aufrufe pro Produktanker aus.
- Die bestehende semikolonbasierte Display-Formatierung wurde entfernt, damit sie nicht versehentlich wieder als Shopware-Query interpretiert wird.
- Die Commerce-Search-Logs enthalten nun zusaetzlich `individualShopQueries`, wenn Split-Lookups vorbereitet wurden.
- Der einzelne Referenzanker-Guard wird bei bereits identitaetsgefilterten Split-Lookup-Ergebnissen uebersprungen, damit ein Treffer zu `Testomat 2000 self clean` nicht spaeter `Testomat 2000 CAL` oder `Testomat 808` aus der zusammengefuehrten Anzeige-Query herausfiltert.
## Bewusst nicht geaendert
- Keine Async-/Parallelisierung. Der Store-API-Pfad ist synchron; Async waere ein separater, groesserer Service-Umbau.
- Keine Aenderung an Intent-Erkennung, Retrieval, Scoring, Ranking oder Shop-Matching.
- Keine neue Produkt-/Marken-Sonderlogik.
- Keine Veraenderung normaler Einzel-Shopqueries.
## Erwartung
Vorher:
```text
Gesendete Suchquery
testomat 2000 self clean; testomat 2000 cal; testomat 808
```
Nachher:
```text
Gesendete Einzelqueries
testomat 2000 self clean
testomat 2000 cal
testomat 808
```
## Geaenderte Dateien
- `src/Agent/AgentRunner.php`
- `config/retriex/chat-messages.yaml`
- `src/Config/ChatMessagesConfig.php`
- `public/assets/styles/base.css`
## Lokale Checks
```text
find src -name '*.php' -print0 | xargs -0 -n1 php -l
python yaml parse for config/retriex/*.yaml
static check: no semicolon display formatter remains
```
Alle lokalen Checks waren gruen. Symfony-Console-Checks muessen in der Zielumgebung mit `vendor/` ausgefuehrt werden.

View File

@@ -0,0 +1,77 @@
# RetrieX Patch p86k - YAML-backed Product Anchor Canonicalization
## Zweck
p86j hat die Multi-Product-Link-Follow-up-Logik verbessert, aber der Core-Pattern-Audit meldete danach einen Warnfund in `src/Agent/AgentRunner.php`:
```text
preg_match('/\btestomat(?:®)?\b.*$/iu', $anchor, $match)
```
p86k entfernt dieses domain-sensitive Pattern aus dem PHP-Core und verlagert die fachliche Start-/Familien-Erkennung in `config/retriex/genre.yaml`.
## Änderung
- `AgentRunner::canonicalizeProductListAnchor()` nutzt keine hart codierte Produktfamilie mehr.
- Neuer YAML-backed Trim-Schritt `trimProductListAnchorToConfiguredStart()`:
- liest `context_resolution.product_list_followup.canonical_start_patterns`
- akzeptiert Named Group `anchor`, Fallback auf Gruppe 1 oder Full Match
- Produktfamilien-Erkennung liest nun aus:
- `context_resolution.product_list_followup.canonical_family_terms`
- `AgentRunnerConfig` stellt Accessor-Methoden bereit.
- `RetriexEffectiveConfigProvider` validiert die neuen YAML-Werte.
## Keine fachliche Änderung
Der Patch ändert nicht:
- Intent-Erkennung
- Retrieval
- Scoring
- Ranking
- Shop-Matching
- Split-Request-Logik aus p86i/p86j
- LAB-CL-Kürzelschutz aus p84
- SiO2-Geräteanker aus p85b
## Erwartung
Der bisherige Effekt bleibt erhalten:
```text
Systembeschreibung Der Testomat® 2000 CAL
=> testomat 2000 cal
```
Die dafür nötige Produktfamilienlogik liegt nun in YAML statt im PHP-Core.
## Lokale Checks
Ausgeführt im Patch-Arbeitsverzeichnis:
```bash
find src -name '*.php' -print0 | xargs -0 -n1 php -l
python3 - <<'PY'
from pathlib import Path
import yaml
for path in Path('config/retriex').glob('*.yaml'):
yaml.safe_load(path.read_text(encoding='utf-8'))
print('YAML parse OK')
PY
grep -RIn "preg_match.*testomat\|testomat.*preg_match" src/Agent/AgentRunner.php
```
Ergebnis:
- PHP lint OK
- YAML parse OK
- kein `preg_match` mit `testomat` in `AgentRunner.php`
## Noch in Zielumgebung ausführen
```bash
php bin/console mto:agent:config:validate
php bin/console mto:agent:regression:test
php bin/console mto:agent:config:audit-source --details
php bin/console mto:agent:config:audit-patterns --details
```

View File

@@ -0,0 +1,77 @@
# RetrieX Patch p86i - Multi Product Split Shopware Requests
## Ziel
Referenzielle Produktlisten-Follow-ups wie
```text
gebe mir links zu den produkten aus dem shop
```
sollen bei mehreren Produktankern keine kombinierte Pseudo-Query mehr an Shopware senden.
Vorher konnte aus dem Verlauf eine zusammengeführte Query entstehen, z. B.:
```text
testomat 2000 cal 808
```
oder als Anzeige/Repair-Query eine Pipe-Liste:
```text
testomat 2000 self clean | testomat 2000 cal | testomat 808
```
Diese Pipe-/Kombi-Query ist für die Shopware-API keine gültige Einzelproduktsuche.
## Änderung
p86i führt einen engen Split-Lookup-Pfad nur für referenzielle Produktlisten-Follow-ups ein:
1. Produktanker werden aus Verlauf/RAG wie bisher generisch extrahiert.
2. Wenn mindestens zwei Produktanker vorhanden sind, wird keine kombinierte Produktlisten-Query an Shopware gesendet.
3. Stattdessen werden echte separate Shopware-Suchen ausgeführt, z. B.:
```text
testomat 2000 self clean
testomat 2000 cal
testomat 808
```
4. Ergebnisse werden per Produktidentität gefiltert und dedupliziert zusammengeführt.
5. Die Query-Anzeige nutzt Semikolon-Trennung als reine Anzeige der Einzelabfragen, keine Pipe-Syntax.
## Scope / Guardrails
Der Patch ist bewusst eng begrenzt:
- nur für `isReferentialProductListShopFollowUpPrompt()`
- nur wenn mindestens zwei Produktanker gefunden wurden
- keine Änderung für normale Einzel-Shopqueries
- keine Änderung an Intent-Erkennung, RAG-Retrieval, Ranking, Scoring oder Shop-Matching
- keine feste Produktliste im Core
- keine Sonderlogik für medizinische Geräte
- kein pauschales `gerät => testomat`
- bestehende Identity-Filterung aus p86g/p86h bleibt aktiv
## Async-Bewertung
Die getrennten Lookups werden bewusst sequenziell und durch `max_anchors` begrenzt ausgeführt.
Der vorhandene `ShopSearchService::search()` / `StoreApiClient`-Pfad ist synchron aufgebaut. Eine echte parallele Store-API-Ausführung würde einen breiteren Service-/HTTP-Client-Umbau erfordern und wurde bewusst nicht in diesen kleinen Hotfix aufgenommen, um keine unrelated Shop-Flows zu riskieren.
## Lokale Checks
```text
php -l src/Agent/AgentRunner.php
find src -name '*.php' -print0 | xargs -0 -n1 php -l
YAML parse OK
```
Symfony-Console-Checks müssen in der Zielumgebung mit `vendor/` ausgeführt werden:
```text
php bin/console mto:agent:config:validate
php bin/console mto:agent:regression:test
php bin/console mto:agent:config:audit-source --details
php bin/console mto:agent:config:audit-patterns --details
```

View File

@@ -620,6 +620,15 @@ body:not(.retriex-show-detail-cards) #chat .retriex-alert {
color: #f8f9fa;
}
.retriex-meta-query__list {
display: grid;
gap: 0.25rem;
}
.retriex-meta-query--multi code {
margin: 0;
}
.retriex-alert {
display: flex;
gap: 0.75rem;

View File

@@ -30,7 +30,9 @@
<img src="/assets/img/logo.svg" style="max-height: 20px;">
<div class="spacer"></div>
<button id="clear" class="btn btn-trans" data-chat-message-text="ui.buttons.clear"></button>
<button id="clear" class="btn btn-trans" data-chat-message-text="ui.buttons.clear"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash-fill" viewBox="0 0 16 16">
<path d="M2.5 1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1H3v9a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V4h.5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H10a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1zm3 4a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 .5-.5M8 5a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7A.5.5 0 0 1 8 5m3 .5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 1 0"/>
</svg></button>
</div>
<div id="ai-cloud" class="ai-cloud d-none"></div>
<div id="chat" class="chat"></div>

View File

@@ -464,13 +464,34 @@ final readonly class AgentRunner
? $shopQueryPreview->searchText
: $shopSearchQuery;
$shopSearchUsedOptimizedQuery = $optimizedShopQuery !== '';
$shopSearchDisplayIndividualQueries = [];
$productListSplitLookupQueries = $this->resolveProductListFollowUpSplitLookupQueries(
prompt: $originalPrompt,
userId: $userId,
commerceHistoryContext: $shopQueryHistoryContext,
knowledgeChunks: $knowledgeChunks
);
if ($productListSplitLookupQueries !== []) {
$shopSearchDisplayIndividualQueries = $productListSplitLookupQueries;
$shopSearchUsedOptimizedQuery = false;
$this->agentLogger->info('Prepared product-list follow-up shop search as separate product anchor lookups', [
'userId' => $userId,
'commerceIntent' => $commerceIntent,
'prompt' => $prompt,
'shopSearchQuery' => $shopSearchQuery,
'splitLookupQueries' => $productListSplitLookupQueries,
]);
}
yield $this->systemMsg(
$this->buildShopSearchMetaMessage(
query: $shopSearchDisplayQuery,
commerceIntent: $commerceIntent,
usedOptimizedQuery: $shopSearchUsedOptimizedQuery,
originalQuery: $shopSearchQuery
originalQuery: $shopSearchQuery,
individualQueries: $shopSearchDisplayIndividualQueries
),
'meta'
);
@@ -481,6 +502,7 @@ final readonly class AgentRunner
'usedOptimizedShopQuery' => $optimizedShopQuery !== '',
'optimizedShopQuery' => $optimizedShopQuery,
'shopSearchQuery' => $shopSearchQuery,
'individualShopQueries' => $shopSearchDisplayIndividualQueries,
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
]);
@@ -503,6 +525,19 @@ final readonly class AgentRunner
);
$shopSearchAttempted = true;
$productListSplitLookupPayload = null;
if ($productListSplitLookupQueries !== []) {
$productListSplitLookupPayload = $this->searchProductListFollowUpSplitLookupQueries(
prompt: $originalPrompt,
userId: $userId,
commerceIntent: $commerceIntent,
commerceHistoryContext: $shopQueryHistoryContext,
queries: $productListSplitLookupQueries
);
$primaryShopResults = $productListSplitLookupPayload['results'];
$primaryShopSearchHadSystemFailure = $productListSplitLookupPayload['hadSystemFailure'];
$primaryShopSearchFailureReason = $productListSplitLookupPayload['failureReason'];
} else {
$primaryShopResults = $this->searchShop(
$shopSearchQuery,
$commerceIntent,
@@ -511,6 +546,7 @@ final readonly class AgentRunner
);
$primaryShopSearchHadSystemFailure = $this->shopSearchService->hadLastSearchSystemFailure();
$primaryShopSearchFailureReason = $this->shopSearchService->getLastSearchFailureReason();
}
if ($primaryShopSearchHadSystemFailure) {
$this->agentLogger->warning('Shop repair skipped after Store API system failure', [
@@ -532,6 +568,7 @@ final readonly class AgentRunner
usedOptimizedQuery: $shopSearchUsedOptimizedQuery,
originalQuery: $shopSearchQuery,
completed: true,
individualQueries: $shopSearchDisplayIndividualQueries,
unavailable: true
),
'meta'
@@ -547,6 +584,14 @@ final readonly class AgentRunner
'usedRepair' => false,
'repairQueries' => [],
];
} else {
if ($productListSplitLookupPayload !== null) {
$repairPayload = [
'results' => $primaryShopResults,
'attemptedRepair' => true,
'usedRepair' => $primaryShopResults !== [],
'repairQueries' => $productListSplitLookupPayload['queries'],
];
} else {
yield $this->systemMsg($this->agentRunnerConfig->getShopRepairCheckMessage(), 'think');
@@ -571,6 +616,7 @@ final readonly class AgentRunner
);
}
}
}
$unguardedShopResults = $repairPayload['results'];
$shopResults = $this->guardDirectProductShopResults($prompt, $shopSearchQuery, $unguardedShopResults);
@@ -588,7 +634,9 @@ final readonly class AgentRunner
$shopResults = $directIdentityRepairPayload['results'];
}
if ($shopSearchDisplayIndividualQueries === []) {
$shopResults = $this->guardShopResultsByReferencedProductAnchor($shopSearchQuery, $shopResults);
}
$shopResults = $this->guardShopResultsByExactRequestedAccessoryCode($prompt, $shopSearchQuery, $shopResults);
$shopResults = $this->sortShopResultsForLengthRequest($prompt, $shopSearchQuery, $shopResults);
$attemptedShopRepair = $repairPayload['attemptedRepair'] || $directIdentityRepairPayload['attemptedRepair'];
@@ -598,14 +646,25 @@ final readonly class AgentRunner
$directIdentityRepairPayload['repairQueries']
)));
$completedShopSearchDisplayQuery = $shopSearchDisplayQuery !== '' ? $shopSearchDisplayQuery : $shopSearchQuery;
$completedShopSearchIndividualQueries = $shopSearchDisplayIndividualQueries;
if (
$usedShopRepair
&& $shopRepairQueries !== []
&& $this->isReferentialProductListShopFollowUpPrompt($prompt)
) {
$completedShopSearchIndividualQueries = $shopRepairQueries;
}
if (!$primaryShopSearchHadSystemFailure) {
yield $this->systemMsg(
$this->buildShopSearchMetaMessage(
query: $shopSearchDisplayQuery !== '' ? $shopSearchDisplayQuery : $shopSearchQuery,
query: $completedShopSearchDisplayQuery,
commerceIntent: $commerceIntent,
usedOptimizedQuery: $shopSearchUsedOptimizedQuery,
originalQuery: $shopSearchQuery,
resultCount: count($shopResults),
individualQueries: $completedShopSearchIndividualQueries,
completed: true,
attemptedRepair: $attemptedShopRepair,
usedRepair: $usedShopRepair
@@ -1534,6 +1593,119 @@ final readonly class AgentRunner
}
}
/**
* Resolve product-list follow-ups into individual product identity lookups
* before calling Shopware. This keeps referential prompts such as "links to
* the products" from sending a combined pseudo-query that Shopware cannot
* interpret as separate products.
*
* @param string[] $knowledgeChunks
* @return string[]
*/
private function resolveProductListFollowUpSplitLookupQueries(
string $prompt,
string $userId,
string $commerceHistoryContext,
array $knowledgeChunks
): array {
if (!$this->isReferentialProductListShopFollowUpPrompt($prompt)) {
return [];
}
$anchors = $this->extractProductListFollowUpAnchorsForLookup(
commerceHistoryContext: $commerceHistoryContext,
userId: $userId,
knowledgeChunks: $knowledgeChunks
);
if (count($anchors) < 2) {
return [];
}
return $this->buildProductListFollowUpAnchorLookupQueries($anchors, '');
}
/**
* Execute product-list follow-up lookups as actual separate Shopware
* searches. The execution is intentionally sequential and bounded by the
* configured max anchor count. The Store API client currently exposes a
* synchronous search contract; introducing concurrent transport here would
* be a wider service-level change and risk unrelated shop flows.
*
* @param string[] $queries
* @return array{results: array<int, ShopProductResult>, hadSystemFailure: bool, failureReason: ?string, queries: string[]}
*/
private function searchProductListFollowUpSplitLookupQueries(
string $prompt,
string $userId,
string $commerceIntent,
string $commerceHistoryContext,
array $queries
): array {
$queries = array_values(array_unique(array_filter(array_map(
static fn(string $query): string => trim($query),
$queries
), static fn(string $query): bool => $query !== '')));
$mergedResults = [];
$seenProducts = [];
$usedQueries = [];
$hadAnySystemFailure = false;
$hadAnySuccessfulSearch = false;
$failureReason = null;
foreach ($queries as $query) {
$queryResults = $this->searchShop(
$query,
$commerceIntent,
$userId,
$commerceHistoryContext
);
if ($this->shopSearchService->hadLastSearchSystemFailure()) {
$hadAnySystemFailure = true;
$failureReason ??= $this->shopSearchService->getLastSearchFailureReason();
continue;
}
$hadAnySuccessfulSearch = true;
$usedQueries[] = $query;
$identityResults = $this->filterProductListFollowUpResultsByAnchorIdentity($queryResults, [$query]);
foreach ($identityResults as $product) {
if (!$product instanceof ShopProductResult) {
continue;
}
$key = $this->buildShopProductDedupeKey($product);
if (isset($seenProducts[$key])) {
continue;
}
$seenProducts[$key] = true;
$mergedResults[] = $product;
}
}
$this->agentLogger->info('Executed product-list follow-up as separate product anchor shop searches', [
'userId' => $userId,
'commerceIntent' => $commerceIntent,
'prompt' => $prompt,
'queries' => $queries,
'usedQueries' => $usedQueries,
'resultCount' => count($mergedResults),
'hadAnySystemFailure' => $hadAnySystemFailure,
'hadAnySuccessfulSearch' => $hadAnySuccessfulSearch,
]);
return [
'results' => $mergedResults,
'hadSystemFailure' => $hadAnySystemFailure && !$hadAnySuccessfulSearch,
'failureReason' => $hadAnySystemFailure && !$hadAnySuccessfulSearch ? $failureReason : null,
'queries' => $usedQueries !== [] ? $usedQueries : $queries,
];
}
/**
* Keep referential product-list follow-ups aligned with the concrete product
* identities mentioned in the previous context. A combined query containing
@@ -1573,9 +1745,14 @@ final readonly class AgentRunner
return $repairPayload;
}
$identityResults = [];
$coveredAnchorKeys = [];
if ($currentResults !== []) {
$identityResults = $this->filterProductListFollowUpResultsByAnchorIdentity($currentResults, $anchors);
if ($identityResults !== []) {
$coveredAnchorKeys = $this->resolveProductListFollowUpCoveredAnchorKeys($identityResults, $anchors);
if ($identityResults !== [] && count($coveredAnchorKeys) >= count($anchors)) {
if (count($identityResults) !== count($currentResults)) {
$this->agentLogger->info('Filtered product-list follow-up shop results to referenced product identities', [
'userId' => $userId,
@@ -1599,8 +1776,22 @@ final readonly class AgentRunner
}
}
$queries = $this->buildProductListFollowUpAnchorLookupQueries($anchors, $shopSearchQuery);
$missingAnchors = $this->filterProductListFollowUpMissingAnchors($anchors, $coveredAnchorKeys);
$lookupAnchors = count($anchors) > 1 && count($coveredAnchorKeys) < count($anchors)
? $anchors
: ($missingAnchors !== [] ? $missingAnchors : $anchors);
$queries = $this->buildProductListFollowUpAnchorLookupQueries($lookupAnchors, $shopSearchQuery);
if ($queries === []) {
if ($identityResults !== []) {
return [
'results' => $identityResults,
'attemptedRepair' => true,
'usedRepair' => true,
'repairQueries' => $repairPayload['repairQueries'] ?? [],
];
}
return $currentResults === [] ? $repairPayload : [
'results' => [],
'attemptedRepair' => true,
@@ -1613,6 +1804,20 @@ final readonly class AgentRunner
$seenProducts = [];
$usedQueries = [];
foreach ($identityResults as $product) {
if (!$product instanceof ShopProductResult) {
continue;
}
$key = $this->buildShopProductDedupeKey($product);
if (isset($seenProducts[$key])) {
continue;
}
$seenProducts[$key] = true;
$mergedResults[] = $product;
}
foreach ($queries as $query) {
$queryResults = $this->searchShop(
$query,
@@ -1647,15 +1852,17 @@ final readonly class AgentRunner
}
}
$repairQueries = array_values(array_unique(array_merge(
$repairPayload['repairQueries'] ?? [],
$queries
)));
if ($mergedResults === []) {
return [
'results' => [],
'attemptedRepair' => true,
'usedRepair' => false,
'repairQueries' => array_values(array_unique(array_merge(
$repairPayload['repairQueries'] ?? [],
$queries
))),
'repairQueries' => $repairQueries,
];
}
@@ -1664,6 +1871,8 @@ final readonly class AgentRunner
'commerceIntent' => $commerceIntent,
'prompt' => $prompt,
'shopSearchQuery' => $shopSearchQuery,
'anchors' => $anchors,
'missingAnchors' => $missingAnchors,
'anchorLookupQueries' => $usedQueries,
'resultCount' => count($mergedResults),
]);
@@ -1672,10 +1881,7 @@ final readonly class AgentRunner
'results' => $mergedResults,
'attemptedRepair' => true,
'usedRepair' => true,
'repairQueries' => array_values(array_unique(array_merge(
$repairPayload['repairQueries'] ?? [],
$usedQueries
))),
'repairQueries' => $repairQueries,
];
}
@@ -1718,22 +1924,16 @@ final readonly class AgentRunner
continue;
}
// Avoid converting a single already-focused product query into a
// redundant retry. The multi-product case remains eligible because
// not all combined-query tokens belong to each individual anchor.
if (count($anchors) === 1) {
$missing = false;
foreach ($tokens as $token) {
if (!isset($combinedTokens[$token])) {
$missing = true;
break;
}
}
if (!$missing) {
// Avoid a redundant retry only when the current query already is
// exactly the same focused product query. A combined multi-product
// query may contain all tokens of one anchor, but that still needs
// an individual lookup for the missing product identity.
if (
count($anchors) === 1
&& $this->buildProductListFollowUpAnchorKey($anchor) === implode(' ', array_keys($combinedTokens))
) {
continue;
}
}
$queries[] = $anchor;
}
@@ -1741,6 +1941,66 @@ final readonly class AgentRunner
return array_values(array_unique($queries));
}
/**
* @param ShopProductResult[] $shopResults
* @param string[] $anchors
* @return array<string, true>
*/
private function resolveProductListFollowUpCoveredAnchorKeys(array $shopResults, array $anchors): array
{
$covered = [];
$anchors = $this->normalizeProductListFollowUpAnchors($anchors);
if ($shopResults === [] || $anchors === []) {
return $covered;
}
foreach ($shopResults as $product) {
if (!$product instanceof ShopProductResult) {
continue;
}
foreach ($anchors as $anchor) {
if (!$this->shopProductIdentityMatchesProductListAnchor($product, $anchor)) {
continue;
}
$key = $this->buildProductListFollowUpAnchorKey($anchor);
if ($key !== '') {
$covered[$key] = true;
}
}
}
return $covered;
}
/**
* @param string[] $anchors
* @param array<string, true> $coveredAnchorKeys
* @return string[]
*/
private function filterProductListFollowUpMissingAnchors(array $anchors, array $coveredAnchorKeys): array
{
$missing = [];
foreach ($this->normalizeProductListFollowUpAnchors($anchors) as $anchor) {
$key = $this->buildProductListFollowUpAnchorKey($anchor);
if ($key === '' || isset($coveredAnchorKeys[$key])) {
continue;
}
$missing[] = $anchor;
}
return $missing;
}
private function buildProductListFollowUpAnchorKey(string $anchor): string
{
return implode(' ', $this->tokenizeShopQueryCandidate($anchor));
}
/**
* @param string[] $anchors
* @return string[]
@@ -2998,7 +3258,7 @@ final readonly class AgentRunner
// A standalone query optimizer may remove words, but it must not add
// model numbers or article-like numbers that are absent from the
// current user input. Otherwise old context can leak into new shop
// searches, for example "Anschlusskabel pH/Redox" -> "testomat 808".
// searches.
if (preg_match('/\d/u', $token) === 1) {
return true;
}
@@ -3765,20 +4025,26 @@ final readonly class AgentRunner
private function canonicalizeProductListAnchor(string $anchor): string
{
$anchor = $this->trimProductListAnchorToConfiguredStart($anchor);
$tokens = $this->tokenizeShopQueryCandidate($anchor);
if ($tokens === []) {
return '';
}
if (($tokens[0] ?? '') !== 'testomat') {
$familyTerms = $this->buildShopQueryTokenSet(
$this->agentRunnerConfig->getShopQueryProductListFollowUpCanonicalFamilyTerms()
);
$familyToken = (string) ($tokens[0] ?? '');
if ($familyTerms === [] || !isset($familyTerms[$familyToken])) {
return trim((string) preg_replace('/\s+/u', ' ', $anchor));
}
if (!isset($tokens[1])) {
return 'testomat';
return $familyToken;
}
$canonical = ['testomat', $tokens[1]];
$canonical = [$familyToken, $tokens[1]];
$variantTerms = $this->buildShopQueryTokenSet(
$this->agentRunnerConfig->getShopQueryPositiveTokenFilterAdjacentVariantTerms()
);
@@ -3797,6 +4063,36 @@ final readonly class AgentRunner
return trim(implode(' ', $canonical));
}
private function trimProductListAnchorToConfiguredStart(string $anchor): string
{
$anchor = trim($anchor);
if ($anchor === '') {
return '';
}
foreach ($this->agentRunnerConfig->getShopQueryProductListFollowUpCanonicalStartPatterns() as $pattern) {
if (@preg_match($pattern, $anchor, $match) !== 1) {
continue;
}
$candidate = '';
if (isset($match['anchor']) && is_string($match['anchor'])) {
$candidate = $match['anchor'];
} elseif (isset($match[1]) && is_string($match[1])) {
$candidate = $match[1];
} elseif (isset($match[0]) && is_string($match[0])) {
$candidate = $match[0];
}
$candidate = trim((string) preg_replace('/\s+/u', ' ', $candidate));
if ($candidate !== '') {
return $candidate;
}
}
return trim((string) preg_replace('/\s+/u', ' ', $anchor));
}
private function guardMainDeviceReferentialShopQueryWithHistoryModelAnchor(
string $prompt,
string $shopSearchQuery,
@@ -6903,6 +7199,9 @@ final readonly class AgentRunner
return $this->normalizeOneLine($rendered);
}
/**
* @param string[] $individualQueries Actual individual Shopware queries that were prepared/executed for split product-list follow-ups.
*/
private function buildShopSearchMetaMessage(
string $query,
string $commerceIntent,
@@ -6912,10 +7211,12 @@ final readonly class AgentRunner
bool $completed = false,
bool $attemptedRepair = false,
bool $usedRepair = false,
bool $unavailable = false
bool $unavailable = false,
array $individualQueries = []
): string {
$query = $this->normalizeOneLine($query);
$originalQuery = $this->normalizeOneLine($originalQuery);
$individualQueries = $this->normalizeProductListFollowUpIndividualQueriesForDisplay($individualQueries);
if ($query === '') {
$query = $originalQuery !== '' ? $originalQuery : $this->agentRunnerConfig->getProductionUiText('shop_meta_fallback_query');
@@ -6963,15 +7264,56 @@ final readonly class AgentRunner
$html .= '<span class="retriex-meta-pill">' . htmlspecialchars($repairLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span>';
}
$html .= '</div>'
. '<div class="retriex-meta-query"><span>' . htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_query_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</span><code>'
$html .= '</div>';
if ($individualQueries !== []) {
$html .= '<div class="retriex-meta-query retriex-meta-query--multi"><span>'
. htmlspecialchars($this->agentRunnerConfig->getProductionUiText('shop_meta_queries_label'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</span><span class="retriex-meta-query__list">';
foreach ($individualQueries as $individualQuery) {
$html .= '<code>' . htmlspecialchars($individualQuery, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code>';
}
$html .= '</span></div>';
} else {
$html .= '<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')
. '</code></div>'
. '</div>';
. '</code></div>';
}
$html .= '</div>';
return $html;
}
/**
* @param string[] $queries
* @return string[]
*/
private function normalizeProductListFollowUpIndividualQueriesForDisplay(array $queries): array
{
$normalized = [];
$seen = [];
foreach ($queries as $query) {
$query = $this->normalizeOneLine((string) $query);
if ($query === '') {
continue;
}
$key = mb_strtolower($query, 'UTF-8');
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$normalized[] = $query;
}
return $normalized;
}
private function buildShopUnavailableMessage(?string $reason): string
{
$reason = $this->normalizeOneLine((string) $reason);

View File

@@ -1863,6 +1863,22 @@ final class AgentRunnerConfig
{
return $this->genreStringList('context_resolution.product_list_followup.anchor_patterns');
}
/**
* @return string[]
*/
public function getShopQueryProductListFollowUpCanonicalStartPatterns(): array
{
return $this->genreStringList('context_resolution.product_list_followup.canonical_start_patterns');
}
/**
* @return string[]
*/
public function getShopQueryProductListFollowUpCanonicalFamilyTerms(): array
{
return $this->genreStringList('context_resolution.product_list_followup.canonical_family_terms');
}
public function isShopQueryRagAnchorEnrichmentEnabled(): bool
{
return $this->getRequiredBool('shop_runtime.context_resolution.rag_anchor_enrichment.enabled');

View File

@@ -411,6 +411,7 @@ final class ChatMessagesConfig
'agent.production_ui.text.shop_meta_repair_checked',
'agent.production_ui.text.shop_meta_eyebrow',
'agent.production_ui.text.shop_meta_query_label',
'agent.production_ui.text.shop_meta_queries_label',
'agent.production_ui.text.shop_meta_query_prefix',
'agent.production_ui.text.shop_meta_intent_prefix',
'agent.production_ui.text.shop_unavailable_default_reason',

View File

@@ -1370,7 +1370,9 @@ final readonly class RetriexEffectiveConfigProvider
$this->validateStringList($this->toList($productListFollowUp['product_terms'] ?? []), 'genre.configuration_values.context_resolution.product_list_followup.product_terms', $errors, $warnings);
$this->validateStringList($this->toList($productListFollowUp['shop_terms'] ?? []), 'genre.configuration_values.context_resolution.product_list_followup.shop_terms', $errors, $warnings);
$this->validateStringList($this->toList($productListFollowUp['noise_terms'] ?? []), 'genre.configuration_values.context_resolution.product_list_followup.noise_terms', $errors, $warnings);
$this->validateStringList($this->toList($productListFollowUp['canonical_family_terms'] ?? []), 'genre.configuration_values.context_resolution.product_list_followup.canonical_family_terms', $errors, $warnings);
$this->validateRegexPatternList($productListFollowUp['anchor_patterns'] ?? [], 'genre.configuration_values.context_resolution.product_list_followup.anchor_patterns', $errors);
$this->validateRegexPatternList($productListFollowUp['canonical_start_patterns'] ?? [], 'genre.configuration_values.context_resolution.product_list_followup.canonical_start_patterns', $errors);
}
$shopQueryRuntime = is_array($configurationValues['shop_query_runtime'] ?? null)