This commit is contained in:
team 1
2026-05-09 20:18:14 +02:00
parent 943c213ac0
commit aae4935d69
6 changed files with 427 additions and 24 deletions

View File

@@ -253,7 +253,12 @@ parameters:
target_role: main_device
knowledge:
- 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:
external_url: Externe URL
rag_knowledge: RAG Wissen

View File

@@ -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.

View File

@@ -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.

View File

@@ -4939,22 +4939,91 @@ final readonly class AgentRunner
/**
* @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
{
$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_ACCESSORY_OR_CONSUMABLE => 0,
ProductRoleResolver::ROLE_AMBIGUOUS_MIXED => 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) {
if (!$product instanceof ShopProductResult) {
continue;
}
$this->countFollowUpActionProductRole($roleCounts, $product);
}
return $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;
@@ -4963,11 +5032,62 @@ final readonly class AgentRunner
++$roleCounts[$role];
}
return [
'shop_query' => $this->normalizeOneLine($shopSearchQuery),
'answer_has_price' => $this->followUpActionAnswerAlreadyContainsPrice($answerText),
'role_counts' => $roleCounts,
];
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
@@ -5026,7 +5146,7 @@ final readonly class AgentRunner
* @param array<int, array<string, mixed>> $actions
* @param array<string, bool> $seenActionKeys
* @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
{
@@ -5062,13 +5182,26 @@ final readonly class AgentRunner
/**
* @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
{
$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']) : '';
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') {
return $context['shop_query'] !== '';
}
@@ -5081,9 +5214,79 @@ final readonly class AgentRunner
return $context['shop_query'] !== '' && $this->isFollowUpRoleFilterMeaningful($targetRole, $context['role_counts']);
}
if ($actionType === 'technical_details') {
return $context['answer_anchor'] !== '';
}
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
*/
@@ -5093,21 +5296,25 @@ final readonly class AgentRunner
return false;
}
$totalProducts = array_sum($roleCounts);
if ($totalProducts <= 0) {
$knownRoleTotal = ($roleCounts[ProductRoleResolver::ROLE_MAIN_DEVICE] ?? 0)
+ ($roleCounts[ProductRoleResolver::ROLE_ACCESSORY_OR_CONSUMABLE] ?? 0);
if ($knownRoleTotal <= 0) {
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
{
$shopQuery = $context['shop_query'];
$rendered = str_replace('{shop_query}', $shopQuery, $prompt);
$rendered = strtr($prompt, [
'{shop_query}' => $context['shop_query'],
'{answer_anchor}' => $context['answer_anchor'],
]);
return $this->normalizeOneLine($rendered);
}

View File

@@ -601,7 +601,7 @@ final class AgentRunnerConfig
'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])) {
$optionalValue = trim((string) $item[$optionalKey]);
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;
}

View File

@@ -543,7 +543,7 @@ final class ChatMessagesConfig
'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])) {
$optionalValue = trim((string) $item[$optionalKey]);
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;
}