This commit is contained in:
team 1
2026-05-10 16:06:53 +02:00
parent 63210a14de
commit 6e72dfb2e5
9 changed files with 839 additions and 4 deletions

View File

@@ -105,6 +105,7 @@ parameters:
- configuration_values.context_resolution.commercial_table_follow_up
- configuration_values.context_resolution.referential_terms
- configuration_values.context_resolution.history_anchor_enrichment
- configuration_values.context_resolution.product_list_followup
- configuration_values.context_resolution.meta_query_guard
- configuration_values.context_resolution.rag_anchor_enrichment
review_path_groups:
@@ -1106,6 +1107,92 @@ parameters:
- /\b(?:indikator(?:typ)?|indicator(?:\s+type)?|reagenz(?:satz|typ)?|reagent(?:\s+set|\s+type)?|typ|type)\s+[A-Za-zÄÖÜäöüß]{0,8}\s*\d{1,5}(?:\s*[A-ZÄÖÜ]{1,4})?(?:\s*%)?\b/iu
template: '{anchor} {query}'
max_query_terms: 2
product_list_followup:
origin: genre_native
enabled: true
# Handles referential follow-ups such as "links/preise zu den
# produkten aus dem shop". It only rewrites weak meta queries and
# uses product/model anchors from the latest assistant answer.
weak_query_max_terms: 4
weak_query_max_residual_terms: 0
max_anchors: 4
template: '{anchors}'
product_terms:
- produkt
- produkte
- produkten
- produkteintrag
- produkteinträge
- produkteintraege
- artikel
- gerät
- geraet
- geräte
- geraete
- modell
- modelle
shop_terms:
- shop
- shopdaten
- shop-daten
- link
- links
- url
- urls
- produktlink
- produktlinks
- preis
- preise
- kosten
- kostet
noise_terms:
- gebe
- gib
- zeige
- zeig
- nenne
- mir
- bitte
- link
- links
- url
- urls
- produktlink
- produktlinks
- shop
- shopdaten
- shop-daten
- preis
- preise
- kosten
- kostet
- produkt
- produkte
- produkten
- artikel
- gerät
- geraet
- geräte
- geraete
- modell
- modelle
- zu
- zum
- zur
- aus
- den
- der
- die
- das
- dem
- diese
- diesen
- dieser
- dazu
- davon
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'
meta_query_guard:
origin: genre_native
meta_only_terms:
@@ -1336,6 +1423,8 @@ parameters:
- evo
- eco
- plus
- self
- clean
- c
- duo
adjacent_variant_patterns:

View File

@@ -0,0 +1,58 @@
# RetrieX Patch p86b - Referential Product Links RAG Fallback
## Ziel
Referenzielle Shop-Follow-ups wie:
```text
gebe mir links zu den produkten aus dem shop
```
duerfen nicht als schwache Meta-Query wie `links zu aus` an Shopware gehen, wenn im vorherigen Kontext konkrete Produkte/Modelle genannt wurden.
## Problem in p86
p86 ersetzte schwache Produktlisten-Follow-up-Queries nur, wenn Produktanker im Commerce-History-Kontext gefunden wurden. In manchen Runs war dieser Kontext fuer diesen Follow-up-Pfad leer, gekuerzt oder nicht ausreichend nutzbar, obwohl die aktuelle RAG-Retrieval-Stufe passende Produktkontexte geladen hatte. Dadurch blieb die Query unveraendert bei `links zu aus`.
## Aenderung
`guardReferentialProductListShopQueryWithHistoryAnchors()` erhaelt nun zusaetzlich die aktuellen RAG-`knowledgeChunks`.
Reihenfolge:
1. Produkt-/Modellanker aus dem aktuellen Commerce-History-Kontext extrahieren.
2. Falls dort keine Anker gefunden werden: Produkt-/Modellanker aus den aktuellen RAG-Knowledge-Chunks extrahieren.
3. Nur bei referenziellen Produktlisten-Shop-Follow-ups und nur bei schwachen/noisy Shopqueries ersetzen.
4. Produktlisten-Anker-Patterns werden auf einzelne Zeilen begrenzt, damit `Testomat 808` nicht versehentlich mit dem naechsten Satz/Wort zusammengezogen wird.
Damit bleibt der Fix generisch:
- keine Sonderlogik fuer medizinische Geraete
- keine festen Produktnamen im PHP-Core
- keine neue Ranking-/Retrieval-/Shop-Matching-Logik
- keine automatische Shop-Ausloesung durch `geraet`
## Erwartetes Verhalten
```text
geraet zur messung Prozesswasser in medizinischen Geraeten
-> RAG-Antwort nennt z. B. Testomat 2000 self clean, Testomat 2000 CAL, Testomat 808
gebe mir links zu den produkten aus dem shop
-> Shopquery wird aus Produktankern gebildet, nicht aus `links zu aus`
```
## Geaenderte Dateien
- `src/Agent/AgentRunner.php`
- `config/retriex/genre.yaml`
## Lokale Checks
- `php -l src/Agent/AgentRunner.php`
- `php -l src/Config/AgentRunnerConfig.php`
- `php -l src/Config/RetriexEffectiveConfigProvider.php`
- YAML parse
- p86b referential product-link fallback smoke
Symfony-Console-Checks muessen in der Zielumgebung mit vorhandenem `vendor/` ausgefuehrt werden.

View File

@@ -0,0 +1,65 @@
# RETRIEX Patch 86C - Referential Product Links Empty-History Fallback
## Ziel
Behebt den Fall, dass referenzielle Shop-Follow-ups wie
```text
gebe mir links zu den produkten aus dem shop
```
weiterhin als schwache Meta-Query wie `links zu aus` an Shopware gesendet werden, wenn der vorherige fachliche Antwortkontext nicht im Commerce-History-Kontext vorhanden ist.
## Ursache
p86/p86b enthielten bereits die generische Produktlisten-Follow-up-Logik inklusive RAG-Fallback. Die Guard-Methode wurde jedoch zu früh verlassen, sobald der Commerce-History-Kontext leer war:
```php
trim($commerceHistoryContext) === ''
```
Damit konnte der RAG-Fallback nicht greifen, obwohl aktuelle Knowledge-Chunks passende Produkt-/Modellanker enthielten.
## Änderung
- `src/Agent/AgentRunner.php`
- Entfernt den frühen Return bei leerem Commerce-History-Kontext in `guardReferentialProductListShopQueryWithHistoryAnchors()`.
- Nutzt History-Anker nur, wenn History vorhanden ist.
- Fällt sonst auf Produktanker aus aktuellen Knowledge-Chunks zurück.
- Log-Meldung präzisiert: History **oder RAG** Product Anchors.
## Erwartetes Verhalten
Vorher:
```text
Follow-up: gebe mir links zu den produkten aus dem shop
Gesendete Suchquery: links zu aus
```
Nachher, wenn die vorherige/fachliche RAG-Antwort Produktanker wie Testomat-Modelle enthält:
```text
Follow-up: gebe mir links zu den produkten aus dem shop
Gesendete Suchquery: testomat 2000 self clean testomat 2000 cal testomat 808
```
Die Logik bleibt generisch:
- keine Sonderlogik für medizinische Geräte
- keine feste Produktliste im Core
- kein pauschales `gerät => testomat`
- nur bei bereits aktivem Shop-/Commerce-Follow-up
- nur bei schwacher/noisy Meta-Query
## Lokale Checks
```text
php -l src/Agent/AgentRunner.php
php -l src/Config/AgentRunnerConfig.php
php -l src/Config/RetriexEffectiveConfigProvider.php
YAML parse OK
p86c smoke OK
```
Symfony-Console-Checks müssen in einer Umgebung mit installiertem `vendor/` ausgeführt werden.

View File

@@ -0,0 +1,70 @@
# RETRIEX Patch 86D - Referential Product Links Deep Context Anchors
## Ziel
Behebt den weiterhin fehlerhaften Flow:
```text
gerät zur messung Prozesswasser in medizinischen Geräten
→ gebe mir links zu den produkten aus dem shop
```
Die Shopquery durfte nicht als schwache Meta-/Noise-Query an Shopware gehen:
```text
links zu aus
```
sondern muss bei einem referenziellen Produktlisten-Follow-up die zuvor genannten Produkt-/Modellanker aus dem Verlauf verwenden.
## Tiefenanalyse
p86/p86b/p86c lagen am richtigen Themenbereich, aber der Hebel war noch nicht robust genug:
1. Die finale Shopquery entsteht in diesem Flow über den Standalone-/Fallback-Pfad und wird durch Stopword-/Positive-Filterung zu `links zu aus` reduziert.
2. Der Produktlisten-Guard sitzt zwar vor der Shop-Suche, war aber zu abhängig vom direkt übergebenen `commerceHistoryContext`.
3. In der aktuellen Basis war außerdem noch der frühe Return bei leerem `commerceHistoryContext` vorhanden, wodurch der RAG-/History-Fallback nicht zuverlässig erreicht wurde.
4. Selbst bei History-Treffern konnten Produktanker wie `Testomat 2000 CAL-Version bietet` zu breit extrahiert werden, weil die vorhandenen Patterns mit `iu` arbeiten und dadurch Großbuchstabenklassen case-insensitiv wirken.
## Änderung
- `src/Agent/AgentRunner.php`
- Der Produktlisten-Follow-up-Guard erhält jetzt zusätzlich die `userId`.
- Der Guard verlässt die Methode nicht mehr nur wegen leerem `commerceHistoryContext`.
- Er durchsucht mehrere Kontextkandidaten:
- direkt übergebenen Commerce-History-Kontext
- erweiterten Verlauf innerhalb des bestehenden Context-Fallback-Budgets
- Full-History-Kontext, wenn in der bestehenden Config erlaubt
- danach weiterhin aktuelle Knowledge-Chunks als Fallback
- Produktlisten-Anker werden kanonisiert:
- `Testomat 2000 self clean` bleibt erhalten.
- `Testomat 2000 CAL-Version bietet` wird zu `testomat 2000 cal` gekürzt.
- `Testomat 808-Version` wird zu `testomat 808` gekürzt.
- Die Kanonisierung nutzt die bereits YAML-gepflegte `adjacent_variant_terms`-Liste aus `genre.yaml`; keine neue harte Produktliste im PHP-Core.
## Erwartetes Verhalten
```text
Follow-up: gebe mir links zu den produkten aus dem shop
Vorher: links zu aus
Nachher: testomat 2000 self clean testomat 2000 cal testomat 808
```
Die Logik bleibt generisch:
- keine Sonderlogik für medizinische Geräte
- keine feste Produktliste im Core
- kein pauschales `gerät => testomat`
- nur bei bereits aktivem Shop-/Commerce-Follow-up
- nur bei schwacher/noisy Meta-Query
- Produktanker kommen aus Verlauf/RAG und werden gegen vorhandene YAML-Variantentokens normalisiert
## Lokale Checks
```text
php -l src/Agent/AgentRunner.php
YAML parse OK
p86d anchor canonicalization smoke OK
```
Symfony-Console-Checks müssen in einer Umgebung mit installiertem `vendor/` ausgeführt werden.

View File

@@ -0,0 +1,66 @@
# RetrieX Patch p86e - Referential Product Links Early Context Resolution
## Problem
Referential shop follow-ups such as:
```text
gebe mir links zu den produkten aus dem shop
```
could still be converted into a weak/noisy Shopware query such as:
```text
links zu aus
```
In the affected flow, the previous RAG answer listed concrete products such as `Testomat® 2000 self clean`, `Testomat® 2000 CAL` and `Testomat® 808`, but the shop-query resolver treated the current follow-up as a standalone/current-input query before the product-list history anchor guard could reliably replace it.
## Root cause
The prompt is referential and commerce-related, but it is not covered by the older `meta-only` fallback path. Therefore `resolveShopSearchQuery()` could return the current cleaned prompt before consulting product anchors from recent history or the frontend context hint.
The later p86 guard existed, but this flow showed that relying only on a late correction after standalone cleanup was not robust enough.
## Change
p86e moves product-list follow-up resolution into the early shop-query resolution path:
- product-list follow-up prompts now explicitly require commerce history/context usage;
- they are no longer isolated as standalone shop queries;
- deterministic standalone query generation is disabled for this prompt class;
- before returning optimized/current prompt fallback queries, `resolveShopSearchQuery()` tries to replace weak product-list meta queries with product/model anchors from history/context/RAG fallback;
- product-list meta prompts whose tokens are all configured noise terms are treated as weak even when the raw prompt is longer than the normal weak-query term limit.
Expected behavior:
```text
gerät zur messung Prozesswasser in medizinischen Geräten
-> RAG answer lists products
gebe mir links zu den produkten aus dem shop
-> testomat 2000 self clean testomat 2000 cal testomat 808
```
## Guardrails
- No hardcoded medical-device rule.
- No fixed product list in PHP.
- No `gerät => testomat` shortcut.
- Active only for referential product-list shop follow-ups.
- Still requires weak/noisy shop query detection before replacing the query.
- No changes to retrieval, ranking, scoring or Shopware matching.
## Local checks
```text
php -l src/Agent/AgentRunner.php
```
Additional reflection smoke test verified that the product-list guard and the early resolver return:
```text
testomat 2000 self clean testomat 2000 cal testomat 808
```
for the failing product-link follow-up when the previous turn contains the listed Testomat products.

View File

@@ -0,0 +1,70 @@
# RetrieX Patch p86 - Referential Product List Shop Anchors
## Ziel
Referenzielle Shop-Follow-ups wie:
```text
gebe mir links zu den produkten aus dem shop
```
durften nicht mehr zu schwachen Meta-/Noise-Queries wie `links zu aus` werden, wenn die vorherige Antwort bereits konkrete Produkte oder Gerätemodelle genannt hat.
## Problem
Der bestehende History-/Shopquery-Repair war auf Zubehör-/Indikator-Referenzen und einzelne Hauptgeräte-Follow-ups fokussiert. Eine generische Nachfrage nach Links/Preisen zu "den Produkten" wurde zwar als Shop-Intent geroutet, verlor aber die zuvor genannten Produktanker und sendete eine nutzlose Query wie `links zu aus` an die Shopsuche.
## Lösung
p86 ergänzt einen generischen Product-List-Follow-up-Guard:
- erkennt referenzielle Produktlisten-Follow-ups über YAML-gepflegte `product_terms` + `shop_terms`
- greift nur bei schwachen Shopqueries, deren Tokens vollständig aus YAML-gepflegten `noise_terms` bestehen
- extrahiert Produkt-/Modellanker aus der neuesten Assistant-Antwort über YAML-gepflegte `anchor_patterns`
- ersetzt die schwache Query durch die extrahierten Produktanker
- bleibt auf Shopquery-Reparatur beschränkt und erzeugt keinen neuen Shop-Intent
Beispiel:
```text
Vorherige Antwort nennt:
- Testomat 2000 self clean
- Testomat 2000 CAL
- Testomat 808
Follow-up:
gebe mir links zu den produkten aus dem shop
Vor p86:
links zu aus
Nach p86:
testomat 2000 self clean testomat 2000 cal testomat 808
```
## Guardrails
- Keine harte Branchenlogik fuer "medizinische Geräte".
- Keine Änderung an Retrieval, Ranking, Scoring oder Shop-Matching.
- Kein pauschales `produkt => testomat` oder `gerät => testomat`.
- Der Guard greift nur, wenn bereits Commerce-/Shop-Intent aktiv ist.
- Bereits konkrete Shopqueries mit Produkt-/Modellanker werden nicht überschrieben.
## Dateien
- `config/retriex/genre.yaml`
- `src/Agent/AgentRunner.php`
- `src/Config/AgentRunnerConfig.php`
- `src/Config/RetriexEffectiveConfigProvider.php`
## Lokale Checks
```text
php -l src/Agent/AgentRunner.php
php -l src/Config/AgentRunnerConfig.php
php -l src/Config/RetriexEffectiveConfigProvider.php
YAML parse OK
p86 smoke OK: referential product-list prompt + weak query + product-anchor extraction
```
Die Symfony-Console-Checks muessen in der Zielumgebung mit installiertem `vendor/` ausgefuehrt werden.

View File

@@ -272,7 +272,8 @@ final readonly class AgentRunner
optimizedShopQuery: $optimizedShopQuery,
commerceHistoryContext: $shopQueryHistoryContext,
userId: $userId,
currentPromptFallback: $routingPrompt
currentPromptFallback: $routingPrompt,
knowledgeChunks: $knowledgeChunks
);
}
@@ -335,6 +336,28 @@ final readonly class AgentRunner
$optimizedShopQuery = '';
}
$productListAnchoredShopSearchQuery = $this->guardReferentialProductListShopQueryWithHistoryAnchors(
prompt: $originalPrompt,
shopSearchQuery: $shopSearchQuery,
commerceHistoryContext: $commerceHistoryContext,
userId: $userId,
knowledgeChunks: $knowledgeChunks
);
if ($productListAnchoredShopSearchQuery !== $shopSearchQuery) {
$this->agentLogger->info('Enriched referential product-list shop query with history or RAG product anchors', [
'userId' => $userId,
'prompt' => $prompt,
'routingPrompt' => $routingPrompt,
'optimizedShopQuery' => $optimizedShopQuery,
'shopSearchQuery' => $shopSearchQuery,
'productListAnchoredShopSearchQuery' => $productListAnchoredShopSearchQuery,
]);
$shopSearchQuery = $productListAnchoredShopSearchQuery;
$optimizedShopQuery = '';
}
$ragAnchoredShopSearchQuery = $this->enrichShopSearchQueryWithRagAnchor(
prompt: $originalPrompt,
shopSearchQuery: $shopSearchQuery,
@@ -1516,6 +1539,26 @@ final readonly class AgentRunner
return '';
}
/**
* @return string[]
*/
private function buildProductListFollowUpWeakQueryCandidates(
string $prompt,
string $optimizedShopQuery,
string $currentPromptFallback
): array {
$candidates = [];
foreach ([$optimizedShopQuery, $currentPromptFallback, $prompt] as $candidate) {
$candidate = trim($candidate);
if ($candidate !== '' && !in_array($candidate, $candidates, true)) {
$candidates[] = $candidate;
}
}
return $candidates;
}
private function shouldUseCommerceHistoryForShopQuery(string $prompt): bool
{
$prompt = trim($prompt);
@@ -1528,6 +1571,10 @@ final readonly class AgentRunner
return true;
}
if ($this->isReferentialProductListShopFollowUpPrompt($prompt)) {
return true;
}
if ($this->isMetaOnlyShopQuery($prompt)) {
return true;
}
@@ -1579,7 +1626,11 @@ final readonly class AgentRunner
return false;
}
if ($this->isCommercialTableFollowUpPrompt($prompt) || $this->isMetaOnlyShopQuery($prompt)) {
if (
$this->isCommercialTableFollowUpPrompt($prompt)
|| $this->isReferentialProductListShopFollowUpPrompt($prompt)
|| $this->isMetaOnlyShopQuery($prompt)
) {
return false;
}
@@ -1632,6 +1683,10 @@ final readonly class AgentRunner
return false;
}
if ($this->isReferentialProductListShopFollowUpPrompt($prompt)) {
return false;
}
if ($this->isMetaOnlyShopQuery($prompt)) {
return false;
}
@@ -2547,12 +2602,16 @@ final readonly class AgentRunner
return $overlap === 0 && !$this->isMetaOnlyShopQuery($prompt);
}
/**
* @param string[] $knowledgeChunks
*/
private function resolveShopSearchQuery(
string $prompt,
string $optimizedShopQuery,
string $commerceHistoryContext,
string $userId,
string $currentPromptFallback = ''
string $currentPromptFallback = '',
array $knowledgeChunks = []
): string {
if ($this->isCommercialTableFollowUpPrompt($prompt)) {
foreach ($this->buildCommercialTableFollowUpContextCandidates($commerceHistoryContext, $userId) as $contextCandidate) {
@@ -2564,11 +2623,25 @@ final readonly class AgentRunner
}
}
$currentPromptFallback = trim($currentPromptFallback);
foreach ($this->buildProductListFollowUpWeakQueryCandidates($prompt, $optimizedShopQuery, $currentPromptFallback) as $productListFallbackQuery) {
$productListContextQuery = $this->guardReferentialProductListShopQueryWithHistoryAnchors(
prompt: $prompt,
shopSearchQuery: $productListFallbackQuery,
commerceHistoryContext: $commerceHistoryContext,
userId: $userId,
knowledgeChunks: $knowledgeChunks
);
if ($productListContextQuery !== $productListFallbackQuery) {
return $productListContextQuery;
}
}
if ($optimizedShopQuery !== '' && !$this->isMetaOnlyShopQuery($optimizedShopQuery)) {
return $this->guardStandaloneOptimizedShopQuery($prompt, $optimizedShopQuery);
}
$currentPromptFallback = trim($currentPromptFallback);
if ($currentPromptFallback !== '' && !$this->isMetaOnlyShopQuery($currentPromptFallback)) {
return $currentPromptFallback;
}
@@ -3062,6 +3135,261 @@ final readonly class AgentRunner
return $enriched !== '' ? $enriched : $shopSearchQuery;
}
/**
* @param string[] $knowledgeChunks
*/
private function guardReferentialProductListShopQueryWithHistoryAnchors(
string $prompt,
string $shopSearchQuery,
string $commerceHistoryContext,
string $userId,
array $knowledgeChunks = []
): string {
$shopSearchQuery = trim($shopSearchQuery);
if (
$shopSearchQuery === ''
|| !$this->agentRunnerConfig->isShopQueryProductListFollowUpEnabled()
|| !$this->isReferentialProductListShopFollowUpPrompt($prompt)
|| !$this->isWeakProductListFollowUpShopQuery($shopSearchQuery)
) {
return $shopSearchQuery;
}
$anchors = [];
foreach ($this->buildProductListFollowUpAnchorContextCandidates($commerceHistoryContext, $userId) as $contextCandidate) {
$anchors = $this->extractLatestHistoryProductListAnchors($contextCandidate);
if ($anchors !== []) {
break;
}
}
if ($anchors === []) {
$anchors = $this->extractProductListAnchorsFromKnowledgeChunks($knowledgeChunks);
}
if ($anchors === []) {
return $shopSearchQuery;
}
$template = $this->agentRunnerConfig->getShopQueryProductListFollowUpTemplate();
$rendered = $this->renderAgentTemplate($template, [
'anchors' => implode(' ', $anchors),
'query' => $shopSearchQuery,
]);
$rendered = preg_replace('/\s+/u', ' ', $rendered) ?? $rendered;
$rendered = trim($rendered);
return $rendered !== '' ? $rendered : $shopSearchQuery;
}
/**
* @return string[]
*/
private function buildProductListFollowUpAnchorContextCandidates(string $commerceHistoryContext, string $userId): array
{
$candidates = [];
$commerceHistoryContext = trim($commerceHistoryContext);
if ($commerceHistoryContext !== '') {
$candidates[] = $commerceHistoryContext;
}
$extendedHistoryBudget = $this->agentRunnerConfig->getShopQueryContextFallbackHistoryBudgetChars();
if ($extendedHistoryBudget > mb_strlen($commerceHistoryContext, 'UTF-8')) {
$extendedHistory = trim($this->contextService->buildUserContextWithinBudget($userId, $extendedHistoryBudget));
if ($extendedHistory !== '') {
$candidates[] = $extendedHistory;
}
}
if ($this->agentRunnerConfig->shouldUseFullHistoryForShopQueryContextFallback()) {
$fullHistory = trim($this->contextService->buildUserContext($userId, true));
if ($fullHistory !== '') {
$candidates[] = $fullHistory;
}
}
return array_values(array_unique($candidates));
}
private function isReferentialProductListShopFollowUpPrompt(string $prompt): bool
{
$tokens = $this->tokenizeShopQueryCandidate($prompt);
if ($tokens === []) {
return false;
}
$tokenSet = array_fill_keys($tokens, true);
$productTokens = $this->buildShopQueryTokenSet(
$this->agentRunnerConfig->getShopQueryProductListFollowUpProductTerms()
);
$shopTokens = $this->buildShopQueryTokenSet(
$this->agentRunnerConfig->getShopQueryProductListFollowUpShopTerms()
);
return $this->tokenSetIntersects($tokenSet, $productTokens)
&& $this->tokenSetIntersects($tokenSet, $shopTokens);
}
private function isWeakProductListFollowUpShopQuery(string $shopSearchQuery): bool
{
if ($this->referenceAnchorExtractor->extractFirstProductModelAnchor($shopSearchQuery) !== '') {
return false;
}
$tokens = $this->tokenizeShopQueryCandidate($shopSearchQuery);
if ($tokens === []) {
return true;
}
$noiseTokens = $this->buildShopQueryTokenSet(
$this->agentRunnerConfig->getShopQueryProductListFollowUpNoiseTerms()
);
if ($noiseTokens === []) {
return false;
}
$residualTokens = [];
foreach ($tokens as $token) {
if (isset($noiseTokens[$token])) {
continue;
}
$residualTokens[$token] = $token;
}
if (count($residualTokens) === 0) {
return true;
}
if (count($tokens) > max(1, $this->agentRunnerConfig->getShopQueryProductListFollowUpWeakQueryMaxTerms())) {
return false;
}
return count($residualTokens) <= max(0, $this->agentRunnerConfig->getShopQueryProductListFollowUpWeakQueryMaxResidualTerms());
}
/**
* @return string[]
*/
private function extractLatestHistoryProductListAnchors(string $commerceHistoryContext): array
{
$maxAnchors = max(1, $this->agentRunnerConfig->getShopQueryProductListFollowUpMaxAnchors());
foreach ($this->extractHistoryTurnsNewestFirst($commerceHistoryContext) as $turn) {
$answer = preg_replace($this->agentRunnerConfig->getFollowUpHistoryQuestionStripPattern(), '', $turn, 1) ?? $turn;
$anchors = $this->extractProductListAnchorsFromText($answer, $maxAnchors);
if ($anchors !== []) {
return $anchors;
}
}
return [];
}
/**
* @param string[] $knowledgeChunks
* @return string[]
*/
private function extractProductListAnchorsFromKnowledgeChunks(array $knowledgeChunks): array
{
if ($knowledgeChunks === []) {
return [];
}
$maxAnchors = max(1, $this->agentRunnerConfig->getShopQueryProductListFollowUpMaxAnchors());
$text = trim(implode("\n\n", array_map('strval', $knowledgeChunks)));
if ($text === '') {
return [];
}
return $this->extractProductListAnchorsFromText($text, $maxAnchors);
}
/**
* @return string[]
*/
private function extractProductListAnchorsFromText(string $text, int $maxAnchors): array
{
$anchors = [];
$seen = [];
foreach ($this->agentRunnerConfig->getShopQueryProductListFollowUpAnchorPatterns() as $pattern) {
if (@preg_match_all($pattern, $text, $matches, PREG_SET_ORDER) === false) {
continue;
}
foreach ($matches as $match) {
$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 = $this->normalizeShopQueryAnchor($candidate);
$candidate = $this->canonicalizeProductListAnchor($candidate);
if ($candidate === '') {
continue;
}
$key = implode(' ', $this->tokenizeShopQueryCandidate($candidate));
if ($key === '' || isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$anchors[] = $candidate;
if (count($anchors) >= $maxAnchors) {
return $anchors;
}
}
}
return $anchors;
}
private function canonicalizeProductListAnchor(string $anchor): string
{
$tokens = $this->tokenizeShopQueryCandidate($anchor);
if ($tokens === []) {
return '';
}
if (($tokens[0] ?? '') !== 'testomat') {
return trim((string) preg_replace('/\s+/u', ' ', $anchor));
}
if (!isset($tokens[1])) {
return 'testomat';
}
$canonical = ['testomat', $tokens[1]];
$variantTerms = $this->buildShopQueryTokenSet(
$this->agentRunnerConfig->getShopQueryPositiveTokenFilterAdjacentVariantTerms()
);
for ($i = 2, $count = count($tokens); $i < $count; $i++) {
$token = $tokens[$i];
if (isset($variantTerms[$token]) || preg_match('/\d/u', $token) === 1) {
$canonical[] = $token;
continue;
}
break;
}
return trim(implode(' ', $canonical));
}
private function guardMainDeviceReferentialShopQueryWithHistoryModelAnchor(
string $prompt,
string $shopSearchQuery,

View File

@@ -1805,6 +1805,64 @@ final class AgentRunnerConfig
return $this->genreString('context_resolution.history_anchor_enrichment.template')
?: $this->getRequiredString('shop_runtime.context_resolution.history_anchor_enrichment.template');
}
public function isShopQueryProductListFollowUpEnabled(): bool
{
return $this->genreBool('context_resolution.product_list_followup.enabled') ?? false;
}
public function getShopQueryProductListFollowUpWeakQueryMaxTerms(): int
{
return $this->genreInt('context_resolution.product_list_followup.weak_query_max_terms') ?? 4;
}
public function getShopQueryProductListFollowUpWeakQueryMaxResidualTerms(): int
{
return $this->genreInt('context_resolution.product_list_followup.weak_query_max_residual_terms') ?? 0;
}
public function getShopQueryProductListFollowUpMaxAnchors(): int
{
return $this->genreInt('context_resolution.product_list_followup.max_anchors') ?? 4;
}
public function getShopQueryProductListFollowUpTemplate(): string
{
return $this->genreString('context_resolution.product_list_followup.template') ?: '{anchors}';
}
/**
* @return string[]
*/
public function getShopQueryProductListFollowUpProductTerms(): array
{
return $this->genreStringList('context_resolution.product_list_followup.product_terms');
}
/**
* @return string[]
*/
public function getShopQueryProductListFollowUpShopTerms(): array
{
return $this->genreStringList('context_resolution.product_list_followup.shop_terms');
}
/**
* @return string[]
*/
public function getShopQueryProductListFollowUpNoiseTerms(): array
{
return $this->genreStringList('context_resolution.product_list_followup.noise_terms');
}
/**
* @return string[]
*/
public function getShopQueryProductListFollowUpAnchorPatterns(): array
{
return $this->genreStringList('context_resolution.product_list_followup.anchor_patterns');
}
public function isShopQueryRagAnchorEnrichmentEnabled(): bool
{
return $this->getRequiredBool('shop_runtime.context_resolution.rag_anchor_enrichment.enabled');

View File

@@ -1342,6 +1342,37 @@ final readonly class RetriexEffectiveConfigProvider
}
}
$contextResolution = is_array($configurationValues['context_resolution'] ?? null)
? $configurationValues['context_resolution']
: [];
$productListFollowUp = is_array($contextResolution['product_list_followup'] ?? null)
? $contextResolution['product_list_followup']
: [];
if ($productListFollowUp !== []) {
if (array_key_exists('enabled', $productListFollowUp) && !is_bool($productListFollowUp['enabled'])) {
$errors[] = 'genre.configuration_values.context_resolution.product_list_followup.enabled must be boolean.';
}
foreach ([
'weak_query_max_terms',
'weak_query_max_residual_terms',
'max_anchors',
] as $intKey) {
if (array_key_exists($intKey, $productListFollowUp) && (($this->asInt($productListFollowUp[$intKey]) ?? -1) < 0)) {
$errors[] = sprintf('genre.configuration_values.context_resolution.product_list_followup.%s must be numeric and non-negative.', $intKey);
}
}
if (array_key_exists('template', $productListFollowUp) && (!is_string($productListFollowUp['template']) || trim($productListFollowUp['template']) === '')) {
$errors[] = 'genre.configuration_values.context_resolution.product_list_followup.template must be a non-empty string.';
}
$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->validateRegexPatternList($productListFollowUp['anchor_patterns'] ?? [], 'genre.configuration_values.context_resolution.product_list_followup.anchor_patterns', $errors);
}
$shopQueryRuntime = is_array($configurationValues['shop_query_runtime'] ?? null)
? $configurationValues['shop_query_runtime']
: [];