p70+71
This commit is contained in:
@@ -253,7 +253,12 @@ parameters:
|
|||||||
target_role: main_device
|
target_role: main_device
|
||||||
knowledge:
|
knowledge:
|
||||||
- label: Technische Details anzeigen
|
- label: Technische Details anzeigen
|
||||||
prompt: Zeige technische Details zur aktuellen Antwort.
|
prompt: Zeige nur zusätzliche technische Details zu {answer_anchor}.
|
||||||
|
action_type: technical_details
|
||||||
|
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'
|
||||||
source_labels:
|
source_labels:
|
||||||
external_url: Externe URL
|
external_url: Externe URL
|
||||||
rag_knowledge: RAG Wissen
|
rag_knowledge: RAG Wissen
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# RetrieX Patch p70 - Follow-up Action Value Guard
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Dieser Patch härtet die Folgeaktionen aus p67-p69 so, dass nur noch wirklich hilfreiche Actions angezeigt werden. Eine Action soll nicht erscheinen, wenn die aktuelle Antwort die angeforderte Information bereits geliefert hat oder wenn der Klick ohne stabilen Kontextanker zu unsauberen Folgeabfragen führen könnte.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Im Flow `Was ist der niedrigste Grenzwert fuer die Wasserhaerte, welcher mit einem Testomaten ueberwacht werden kann?` konnte nach einer bereits technischen Antwort weiterhin `Technische Details anzeigen` erscheinen. Beim Klick war der Prompt zu generisch (`zur aktuellen Antwort`) und konnte dadurch fremde technische Kontexte wie `TH2005` anziehen, obwohl der relevante Kontext `Testomat 808 / 0,02 °dH / Indikatortyp 300` ist.
|
||||||
|
|
||||||
|
Außerdem sollten Rollenfilter wie `Nur Zubehoer anzeigen` nicht angezeigt werden, wenn die erkannte aktuelle Auswahl bereits nur aus Zubehoer besteht. Unbekannte Rollen duerfen eine eigentlich redundante Filteraktion nicht kuenstlich sinnvoll erscheinen lassen.
|
||||||
|
|
||||||
|
## Loesung
|
||||||
|
|
||||||
|
- `buildFollowUpActionContext()` normalisiert die finale Antwort und berechnet zusaetzliche Action-Kontextsignale:
|
||||||
|
- `answer_text`
|
||||||
|
- `answer_anchor` aus bestehenden Referenzanker-Patterns (Produktmodell + Messwert)
|
||||||
|
- `answer_detail_score` aus Produktmodell-/Messwertankern
|
||||||
|
- Knowledge-Action `Technische Details anzeigen` ist jetzt als `action_type: technical_details` konfiguriert.
|
||||||
|
- Die technische Detail-Action wird unterdrueckt, wenn:
|
||||||
|
- kein stabiler Antwortanker vorhanden ist,
|
||||||
|
- die Antwort bereits genug technische Detailanker enthaelt,
|
||||||
|
- oder konfigurierte Antwortmuster wie Grenzwert, Messbereich, Indikatortyp, Einsatzgebiet oder technische Einordnung bereits vorkommen.
|
||||||
|
- Falls die technische Detail-Action doch sinnvoll ist, wird der Klick-Prompt mit `{answer_anchor}` verankert statt generisch auf `aktuelle Antwort` zu zeigen.
|
||||||
|
- Rollenfilter werden nur noch anhand bekannter Hauptgeraet-/Zubehoer-Rollen bewertet. `unknown` oder `ambiguous` zaehlt nicht mehr als Grund, einen redundanten Rollenfilter anzuzeigen.
|
||||||
|
- Action-Definitionen koennen nun YAML-seitig folgende Guard-Metadaten tragen:
|
||||||
|
- `requires_answer_anchor`
|
||||||
|
- `hide_when_answer_detail_score_at_least`
|
||||||
|
- `hide_when_answer_matches_any`
|
||||||
|
|
||||||
|
## Bewusst nicht geaendert
|
||||||
|
|
||||||
|
- Keine Aenderung an Retrieval, Scoring, Ranking, Intent, Shop-Matching oder PromptBuilder.
|
||||||
|
- Keine neuen PHP-Core-Keywordlisten fuer Testomat, Indikator oder TH2005.
|
||||||
|
- Die neuen Guard-Begriffe liegen YAML-konfigurierbar in `chat-messages.yaml`.
|
||||||
|
- `buildShopProductCardsMessage()` bleibt weiterhin nicht eingehängt.
|
||||||
|
|
||||||
|
## Geaenderte Dateien
|
||||||
|
|
||||||
|
- `src/Agent/AgentRunner.php`
|
||||||
|
- `src/Config/AgentRunnerConfig.php`
|
||||||
|
- `src/Config/ChatMessagesConfig.php`
|
||||||
|
- `config/retriex/chat-messages.yaml`
|
||||||
|
|
||||||
|
## Lokale Checks
|
||||||
|
|
||||||
|
Ausgefuehrt im ZIP-Arbeitsstand ohne `vendor/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -l src/Agent/AgentRunner.php
|
||||||
|
php -l src/Config/AgentRunnerConfig.php
|
||||||
|
php -l src/Config/ChatMessagesConfig.php
|
||||||
|
python3 -c 'import yaml; yaml.safe_load(open("config/retriex/chat-messages.yaml"))'
|
||||||
|
```
|
||||||
|
|
||||||
|
Zusätzlich wurde per Smoke-Test geprueft, dass `ChatMessagesConfig::getActionList()` die neuen Action-Metadaten erhaelt.
|
||||||
|
|
||||||
|
## Empfohlene Regressionstests
|
||||||
|
|
||||||
|
1. `Was ist der niedrigste Grenzwert fuer die Wasserhaerte, welcher mit einem Testomaten ueberwacht werden kann?`
|
||||||
|
- Erwartung: Antwort bleibt fachlich bei `0,02 °dH / Testomat 808`.
|
||||||
|
- Erwartung: keine redundante Action `Technische Details anzeigen`, wenn die Antwort den technischen Kern bereits enthaelt.
|
||||||
|
|
||||||
|
2. Folgefrage: `mit welchem indikator wird der wert gemessen`
|
||||||
|
- Erwartung: Antwort bleibt bei `Indikatortyp 300`.
|
||||||
|
- Erwartung: keine technische Detail-Action, wenn Indikatortyp/Grenzwert bereits beantwortet wurde.
|
||||||
|
|
||||||
|
3. `testomat 808 indikatoren`
|
||||||
|
- Erwartung: keine `Nur Geraete anzeigen`-Action bei reiner Indikator-/Zubehoerliste.
|
||||||
|
- Erwartung: keine `Nur Zubehoer anzeigen`-Action, wenn die erkannte Auswahl bereits nur Zubehoer ist.
|
||||||
|
- Erwartung: keine Action-Karte, wenn keine sinnvolle Action uebrig bleibt.
|
||||||
|
|
||||||
|
4. Gemischte Shop-Liste mit erkannten Hauptgeraeten und erkanntem Zubehoer
|
||||||
|
- Erwartung: Rollenfilter erscheinen nur, wenn sie die aktuelle bekannte Rollenmenge wirklich verengen.
|
||||||
|
|
||||||
|
5. Smalltalk / No-Evidence
|
||||||
|
- Erwartung: keine Follow-up-Actions.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# RetrieX Patch p71 - Follow-up Action Displayed Selection Guard
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Dieser Patch haertet die Folgeaktionen aus p67-p70 weiter. Rollenfilter-Actions duerfen nicht mehr aus der kompletten Shop-Rohmenge abgeleitet werden, wenn die finale Antwort nur eine engere Produktauswahl zeigt.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Im Flow:
|
||||||
|
|
||||||
|
1. `Was ist der niedrigste Grenzwert fuer die Wasserhaerte, welcher mit einem Testomaten ueberwacht werden kann?`
|
||||||
|
2. `mit welchem indikator wird der wert gemessen`
|
||||||
|
3. `was kostet der indikator`
|
||||||
|
|
||||||
|
lieferte die Antwort korrekt den `Testomat® 808 Indikator 300 2 x 100 ml` mit Preis. Trotzdem erschienen danach `Nur Zubehoer anzeigen` und `Nur Geraete anzeigen`, weil die Follow-up-Actions noch die gesamte Shop-Rohmenge auswerteten. Diese Rohmenge konnte weitere Treffer enthalten, z. B. das zugehoerige Geraet `Testomat® 808`. Dadurch wirkte `Nur Geraete anzeigen` scheinbar sinnvoll, obwohl die sichtbare Antwort bereits ein einzelner Zubehoer-/Indikator-Treffer war.
|
||||||
|
|
||||||
|
Beim Klick entstand eine Folgefrage wie `Suche im Shop nach 300 indikator und zeige daraus nur Geraete.`, wodurch der Kontext in eine fragwuerdige Geraete-Schleife kippen konnte.
|
||||||
|
|
||||||
|
## Loesung
|
||||||
|
|
||||||
|
- `buildFollowUpActionContext()` berechnet weiterhin Rollen aus den Shopdaten.
|
||||||
|
- Wenn die finale Antwort konkrete Shop-Produkte sichtbar enthaelt, werden fuer Rollenfilter aber bevorzugt nur diese angezeigten Produkte bewertet.
|
||||||
|
- Angezeigte Produkte werden robust erkannt ueber:
|
||||||
|
- Produktnummern in der Antwort,
|
||||||
|
- oder hinreichend spezifische Produktnamen in der Antwort.
|
||||||
|
- Sehr kurze/generische Produktnamen wie `Testomat® 808` werden nicht per bloßer Namens-Substring-Erkennung als sichtbar gezaehlt, damit sie nicht durch laengere Indikatornamen falsch mitgezogen werden.
|
||||||
|
- Wenn die sichtbare Auswahl nur aus Zubehoer besteht, erscheinen weder `Nur Zubehoer anzeigen` noch `Nur Geraete anzeigen`.
|
||||||
|
- Wenn die sichtbare Auswahl nur aus Geraeten besteht, erscheinen ebenfalls keine redundanten Rollenfilter.
|
||||||
|
- Nur echte gemischte sichtbare Auswahlen koennen Rollenfilter-Actions erzeugen.
|
||||||
|
|
||||||
|
## Bewusst nicht geaendert
|
||||||
|
|
||||||
|
- Keine Aenderung an Retrieval, Scoring, Ranking, Intent, Shop-Matching oder PromptBuilder.
|
||||||
|
- Keine neuen fachlichen Sonderregeln fuer Testomat 808, Indikatortyp 300 oder TH2005.
|
||||||
|
- Keine Aenderung an YAML-Action-Labels oder Frontend-Rendering.
|
||||||
|
- `buildShopProductCardsMessage()` bleibt weiterhin nicht eingehängt.
|
||||||
|
|
||||||
|
## Geaenderte Dateien
|
||||||
|
|
||||||
|
- `src/Agent/AgentRunner.php`
|
||||||
|
- `patch_history/RETRIEX_PATCH_71_FOLLOWUP_ACTION_DISPLAYED_SELECTION_GUARD_README.md`
|
||||||
|
|
||||||
|
## Lokale Checks
|
||||||
|
|
||||||
|
Ausgefuehrt im ZIP-Arbeitsstand ohne `vendor/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -l src/Agent/AgentRunner.php
|
||||||
|
php -l src/Config/AgentRunnerConfig.php
|
||||||
|
php -l src/Config/ChatMessagesConfig.php
|
||||||
|
python3 -c 'import yaml; yaml.safe_load(open("config/retriex/chat-messages.yaml"))'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Empfohlene Regressionstests
|
||||||
|
|
||||||
|
1. Flow `0,02 °dH -> Indikatortyp 300 -> was kostet der indikator`
|
||||||
|
- Erwartung: Antwort bleibt bei `Testomat® 808 Indikator 300 2 x 100 ml` / `140001` / `83,30 €`.
|
||||||
|
- Erwartung: keine Rollenfilter-Actions, weil die sichtbare Auswahl bereits ein einzelner Zubehoer-/Indikator-Treffer ist.
|
||||||
|
|
||||||
|
2. Klick auf eine alte/noch vorhandene `Nur Geraete anzeigen`-Action aus dem Verlauf
|
||||||
|
- Erwartung: Nach der Antwort mit dem einzelnen Geraet entstehen keine neuen redundanten Rollenfilter-Actions.
|
||||||
|
|
||||||
|
3. `testomat 808 indikatoren`
|
||||||
|
- Erwartung: Wenn die sichtbare Antwort nur Indikatoren/Zubehoer zeigt, keine `Nur Geraete anzeigen`-Action und keine redundante `Nur Zubehoer anzeigen`-Action.
|
||||||
|
|
||||||
|
4. Gemischte sichtbare Shop-Auswahl mit Hauptgeraeten und Zubehoer
|
||||||
|
- Erwartung: Rollenfilter duerfen erscheinen, weil sie die sichtbare Auswahl wirklich verengen.
|
||||||
|
|
||||||
|
5. Smalltalk / No-Evidence
|
||||||
|
- Erwartung: keine Follow-up-Actions.
|
||||||
@@ -4939,35 +4939,155 @@ final readonly class AgentRunner
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param ShopProductResult[] $shopResults
|
* @param ShopProductResult[] $shopResults
|
||||||
* @return array{shop_query:string, answer_has_price:bool, role_counts:array<string,int>}
|
* @return array{shop_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array<string,int>}
|
||||||
*/
|
*/
|
||||||
private function buildFollowUpActionContext(array $shopResults, string $shopSearchQuery, string $answerText): array
|
private function buildFollowUpActionContext(array $shopResults, string $shopSearchQuery, string $answerText): array
|
||||||
{
|
{
|
||||||
$roleCounts = [
|
$plainAnswerText = $this->normalizeOneLine($this->plainTextFromHtml($answerText));
|
||||||
|
$roleCounts = $this->buildFollowUpActionRoleCounts($shopResults);
|
||||||
|
$displayedRoleCounts = $this->buildFollowUpActionDisplayedRoleCounts($shopResults, $plainAnswerText);
|
||||||
|
|
||||||
|
if (array_sum($displayedRoleCounts) > 0) {
|
||||||
|
$roleCounts = $displayedRoleCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'shop_query' => $this->normalizeOneLine($shopSearchQuery),
|
||||||
|
'answer_has_price' => $this->followUpActionAnswerAlreadyContainsPrice($plainAnswerText),
|
||||||
|
'answer_text' => $plainAnswerText,
|
||||||
|
'answer_anchor' => $this->buildFollowUpActionAnswerAnchor($plainAnswerText),
|
||||||
|
'answer_detail_score' => $this->calculateFollowUpActionAnswerDetailScore($plainAnswerText),
|
||||||
|
'role_counts' => $roleCounts,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function emptyFollowUpActionRoleCounts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
ProductRoleResolver::ROLE_MAIN_DEVICE => 0,
|
ProductRoleResolver::ROLE_MAIN_DEVICE => 0,
|
||||||
ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE => 0,
|
ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE => 0,
|
||||||
ProductRoleResolver::ROLE_AMBIGUOUS_MIXED => 0,
|
ProductRoleResolver::ROLE_AMBIGUOUS_MIXED => 0,
|
||||||
ProductRoleResolver::ROLE_UNKNOWN => 0,
|
ProductRoleResolver::ROLE_UNKNOWN => 0,
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ShopProductResult[] $shopResults
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function buildFollowUpActionRoleCounts(array $shopResults): array
|
||||||
|
{
|
||||||
|
$roleCounts = $this->emptyFollowUpActionRoleCounts();
|
||||||
|
|
||||||
foreach ($shopResults as $product) {
|
foreach ($shopResults as $product) {
|
||||||
if (!$product instanceof ShopProductResult) {
|
if (!$product instanceof ShopProductResult) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$role = $this->resolveFollowUpActionShopProductRole($product);
|
$this->countFollowUpActionProductRole($roleCounts, $product);
|
||||||
if (!array_key_exists($role, $roleCounts)) {
|
|
||||||
$role = ProductRoleResolver::ROLE_UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
++$roleCounts[$role];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return $roleCounts;
|
||||||
'shop_query' => $this->normalizeOneLine($shopSearchQuery),
|
}
|
||||||
'answer_has_price' => $this->followUpActionAnswerAlreadyContainsPrice($answerText),
|
|
||||||
'role_counts' => $roleCounts,
|
/**
|
||||||
];
|
* @param ShopProductResult[] $shopResults
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function buildFollowUpActionDisplayedRoleCounts(array $shopResults, string $answerText): array
|
||||||
|
{
|
||||||
|
$roleCounts = $this->emptyFollowUpActionRoleCounts();
|
||||||
|
if ($answerText === '') {
|
||||||
|
return $roleCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($shopResults as $product) {
|
||||||
|
if (!$product instanceof ShopProductResult) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isFollowUpActionProductDisplayedInAnswer($product, $answerText)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->countFollowUpActionProductRole($roleCounts, $product);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $roleCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $roleCounts
|
||||||
|
*/
|
||||||
|
private function countFollowUpActionProductRole(array &$roleCounts, ShopProductResult $product): void
|
||||||
|
{
|
||||||
|
$role = $this->resolveFollowUpActionShopProductRole($product);
|
||||||
|
if (!array_key_exists($role, $roleCounts)) {
|
||||||
|
$role = ProductRoleResolver::ROLE_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
++$roleCounts[$role];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isFollowUpActionProductDisplayedInAnswer(ShopProductResult $product, string $answerText): bool
|
||||||
|
{
|
||||||
|
$normalizedAnswer = mb_strtolower($this->normalizeOneLine($answerText), 'UTF-8');
|
||||||
|
if ($normalizedAnswer === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$productNumber = $this->normalizeOneLine((string) $product->productNumber);
|
||||||
|
if ($productNumber !== '' && mb_strlen($productNumber, 'UTF-8') >= 3 && str_contains($normalizedAnswer, mb_strtolower($productNumber, 'UTF-8'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$productName = mb_strtolower($this->normalizeOneLine($product->name), 'UTF-8');
|
||||||
|
if ($productName === '' || mb_strlen($productName, 'UTF-8') < 16) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
preg_match_all('/[\p{L}\p{N}]+/u', $productName, $matches);
|
||||||
|
$tokens = array_values(array_unique($matches[0] ?? []));
|
||||||
|
if (count($tokens) < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_contains($normalizedAnswer, $productName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFollowUpActionAnswerAnchor(string $answerText): string
|
||||||
|
{
|
||||||
|
$anchors = [];
|
||||||
|
|
||||||
|
$modelAnchor = $this->referenceAnchorExtractor->extractFirstProductModelAnchor($answerText);
|
||||||
|
if ($modelAnchor !== '') {
|
||||||
|
$anchors[] = $modelAnchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
$measurementAnchor = $this->referenceAnchorExtractor->extractFirstMeasurementValueAnchor($answerText);
|
||||||
|
if ($measurementAnchor !== '') {
|
||||||
|
$anchors[] = $measurementAnchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->normalizeOneLine(implode(' ', array_values(array_unique($anchors))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calculateFollowUpActionAnswerDetailScore(string $answerText): int
|
||||||
|
{
|
||||||
|
$score = 0;
|
||||||
|
|
||||||
|
if ($this->referenceAnchorExtractor->extractFirstProductModelAnchor($answerText) !== '') {
|
||||||
|
++$score;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->referenceAnchorExtractor->extractFirstMeasurementValueAnchor($answerText) !== '') {
|
||||||
|
++$score;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $score;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveFollowUpActionShopProductRole(ShopProductResult $product): string
|
private function resolveFollowUpActionShopProductRole(ShopProductResult $product): string
|
||||||
@@ -5026,7 +5146,7 @@ final readonly class AgentRunner
|
|||||||
* @param array<int, array<string, mixed>> $actions
|
* @param array<int, array<string, mixed>> $actions
|
||||||
* @param array<string, bool> $seenActionKeys
|
* @param array<string, bool> $seenActionKeys
|
||||||
* @param array<int, array<string, mixed>> $items
|
* @param array<int, array<string, mixed>> $items
|
||||||
* @param array{shop_query:string, answer_has_price:bool, role_counts:array<string,int>} $context
|
* @param array{shop_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array<string,int>} $context
|
||||||
*/
|
*/
|
||||||
private function appendFollowUpActions(array &$actions, array &$seenActionKeys, array $items, array $context): void
|
private function appendFollowUpActions(array &$actions, array &$seenActionKeys, array $items, array $context): void
|
||||||
{
|
{
|
||||||
@@ -5062,13 +5182,26 @@ final readonly class AgentRunner
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $item
|
* @param array<string, mixed> $item
|
||||||
* @param array{shop_query:string, answer_has_price:bool, role_counts:array<string,int>} $context
|
* @param array{shop_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array<string,int>} $context
|
||||||
*/
|
*/
|
||||||
private function shouldShowFollowUpAction(array $item, array $context): bool
|
private function shouldShowFollowUpAction(array $item, array $context): bool
|
||||||
{
|
{
|
||||||
$actionType = isset($item['action_type']) && is_scalar($item['action_type']) ? trim((string) $item['action_type']) : '';
|
$actionType = isset($item['action_type']) && is_scalar($item['action_type']) ? trim((string) $item['action_type']) : '';
|
||||||
$targetRole = isset($item['target_role']) && is_scalar($item['target_role']) ? trim((string) $item['target_role']) : '';
|
$targetRole = isset($item['target_role']) && is_scalar($item['target_role']) ? trim((string) $item['target_role']) : '';
|
||||||
|
|
||||||
|
if ($this->followUpActionAnswerMatchesAnyConfiguredPattern($context['answer_text'], $item['hide_when_answer_matches_any'] ?? [])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hideAtDetailScore = $this->optionalFollowUpActionInt($item, 'hide_when_answer_detail_score_at_least');
|
||||||
|
if ($hideAtDetailScore !== null && $context['answer_detail_score'] >= $hideAtDetailScore) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->optionalFollowUpActionBool($item, 'requires_answer_anchor') && $context['answer_anchor'] === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if ($actionType === 'shop_search') {
|
if ($actionType === 'shop_search') {
|
||||||
return $context['shop_query'] !== '';
|
return $context['shop_query'] !== '';
|
||||||
}
|
}
|
||||||
@@ -5081,9 +5214,79 @@ final readonly class AgentRunner
|
|||||||
return $context['shop_query'] !== '' && $this->isFollowUpRoleFilterMeaningful($targetRole, $context['role_counts']);
|
return $context['shop_query'] !== '' && $this->isFollowUpRoleFilterMeaningful($targetRole, $context['role_counts']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($actionType === 'technical_details') {
|
||||||
|
return $context['answer_anchor'] !== '';
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function optionalFollowUpActionBool(array $item, string $key): bool
|
||||||
|
{
|
||||||
|
if (!array_key_exists($key, $item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $item[$key];
|
||||||
|
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_scalar($value)) {
|
||||||
|
$normalized = mb_strtolower(trim((string) $value), 'UTF-8');
|
||||||
|
|
||||||
|
return in_array($normalized, ['1', 'true', 'yes', 'on'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function optionalFollowUpActionInt(array $item, string $key): ?int
|
||||||
|
{
|
||||||
|
if (!isset($item[$key]) || !is_scalar($item[$key])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim((string) $item[$key]);
|
||||||
|
if ($value === '' || !preg_match('/^-?\d+$/', $value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $patterns
|
||||||
|
*/
|
||||||
|
private function followUpActionAnswerMatchesAnyConfiguredPattern(string $answerText, mixed $patterns): bool
|
||||||
|
{
|
||||||
|
if (!is_array($patterns) || $answerText === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($patterns as $pattern) {
|
||||||
|
if (!is_scalar($pattern)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pattern = trim((string) $pattern);
|
||||||
|
if ($pattern === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (@preg_match($pattern, '') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (@preg_match($pattern, $answerText) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, int> $roleCounts
|
* @param array<string, int> $roleCounts
|
||||||
*/
|
*/
|
||||||
@@ -5093,21 +5296,25 @@ final readonly class AgentRunner
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$totalProducts = array_sum($roleCounts);
|
$knownRoleTotal = ($roleCounts[ProductRoleResolver::ROLE_MAIN_DEVICE] ?? 0)
|
||||||
if ($totalProducts <= 0) {
|
+ ($roleCounts[ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE] ?? 0);
|
||||||
|
|
||||||
|
if ($knownRoleTotal <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $roleCounts[$targetRole] < $totalProducts;
|
return $roleCounts[$targetRole] < $knownRoleTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{shop_query:string, answer_has_price:bool, role_counts:array<string,int>} $context
|
* @param array{shop_query:string, answer_has_price:bool, answer_text:string, answer_anchor:string, answer_detail_score:int, role_counts:array<string,int>} $context
|
||||||
*/
|
*/
|
||||||
private function renderFollowUpActionPrompt(string $prompt, array $context): string
|
private function renderFollowUpActionPrompt(string $prompt, array $context): string
|
||||||
{
|
{
|
||||||
$shopQuery = $context['shop_query'];
|
$rendered = strtr($prompt, [
|
||||||
$rendered = str_replace('{shop_query}', $shopQuery, $prompt);
|
'{shop_query}' => $context['shop_query'],
|
||||||
|
'{answer_anchor}' => $context['answer_anchor'],
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->normalizeOneLine($rendered);
|
return $this->normalizeOneLine($rendered);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -601,7 +601,7 @@ final class AgentRunnerConfig
|
|||||||
'prompt' => $prompt,
|
'prompt' => $prompt,
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach (['action_type', 'target_role'] as $optionalKey) {
|
foreach (['action_type', 'target_role', 'hide_when_answer_detail_score_at_least'] as $optionalKey) {
|
||||||
if (isset($item[$optionalKey]) && is_scalar($item[$optionalKey])) {
|
if (isset($item[$optionalKey]) && is_scalar($item[$optionalKey])) {
|
||||||
$optionalValue = trim((string) $item[$optionalKey]);
|
$optionalValue = trim((string) $item[$optionalKey]);
|
||||||
if ($optionalValue !== '') {
|
if ($optionalValue !== '') {
|
||||||
@@ -610,6 +610,28 @@ final class AgentRunnerConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('requires_answer_anchor', $item) && (is_bool($item['requires_answer_anchor']) || is_scalar($item['requires_answer_anchor']))) {
|
||||||
|
$action['requires_answer_anchor'] = $item['requires_answer_anchor'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($item['hide_when_answer_matches_any']) && is_array($item['hide_when_answer_matches_any'])) {
|
||||||
|
$patterns = [];
|
||||||
|
foreach ($item['hide_when_answer_matches_any'] as $pattern) {
|
||||||
|
if (!is_scalar($pattern)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pattern = trim((string) $pattern);
|
||||||
|
if ($pattern !== '') {
|
||||||
|
$patterns[] = $pattern;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($patterns !== []) {
|
||||||
|
$action['hide_when_answer_matches_any'] = array_values(array_unique($patterns));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$out[] = $action;
|
$out[] = $action;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -543,7 +543,7 @@ final class ChatMessagesConfig
|
|||||||
'prompt' => $prompt,
|
'prompt' => $prompt,
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach (['action_type', 'target_role'] as $optionalKey) {
|
foreach (['action_type', 'target_role', 'hide_when_answer_detail_score_at_least'] as $optionalKey) {
|
||||||
if (isset($item[$optionalKey]) && is_scalar($item[$optionalKey])) {
|
if (isset($item[$optionalKey]) && is_scalar($item[$optionalKey])) {
|
||||||
$optionalValue = trim((string) $item[$optionalKey]);
|
$optionalValue = trim((string) $item[$optionalKey]);
|
||||||
if ($optionalValue !== '') {
|
if ($optionalValue !== '') {
|
||||||
@@ -552,6 +552,28 @@ final class ChatMessagesConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('requires_answer_anchor', $item) && (is_bool($item['requires_answer_anchor']) || is_scalar($item['requires_answer_anchor']))) {
|
||||||
|
$action['requires_answer_anchor'] = $item['requires_answer_anchor'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($item['hide_when_answer_matches_any']) && is_array($item['hide_when_answer_matches_any'])) {
|
||||||
|
$patterns = [];
|
||||||
|
foreach ($item['hide_when_answer_matches_any'] as $pattern) {
|
||||||
|
if (!is_scalar($pattern)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pattern = trim((string) $pattern);
|
||||||
|
if ($pattern !== '') {
|
||||||
|
$patterns[] = $pattern;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($patterns !== []) {
|
||||||
|
$action['hide_when_answer_matches_any'] = array_values(array_unique($patterns));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$out[] = $action;
|
$out[] = $action;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user