optimize technical truth

This commit is contained in:
team 1
2026-04-28 12:31:13 +02:00
parent fd8516c7f8
commit 0ee8799b9d
8 changed files with 523 additions and 21 deletions

View File

@@ -1427,6 +1427,7 @@ final readonly class AgentRunner
if ($hasShopResults) {
return $this->buildNoLlmShopFallbackAnswer(
prompt: $prompt,
hasKnowledge: $hasKnowledge,
shopResults: $shopResults
);
@@ -1453,15 +1454,22 @@ final readonly class AgentRunner
/**
* @param ShopProductResult[] $shopResults
*/
private function buildNoLlmShopFallbackAnswer(bool $hasKnowledge, array $shopResults): string
private function buildNoLlmShopFallbackAnswer(string $prompt, bool $hasKnowledge, array $shopResults): string
{
$intro = $hasKnowledge
? $this->agentRunnerConfig->getNoLlmFallbackShopWithKnowledgeMessage()
: $this->agentRunnerConfig->getNoLlmFallbackShopOnlyMessage();
$requestedProductRole = $this->resolveNoLlmRequestedProductRole($prompt);
$lines = [$intro, ''];
$lines = [$intro];
foreach ($this->buildNoLlmShopProductLines($shopResults) as $line) {
if ($this->hasOnlyNoLlmAccessoryResultsForMainDeviceRequest($requestedProductRole, $shopResults)) {
$lines[] = $this->agentRunnerConfig->getNoLlmFallbackAccessoryOnlyForMainDeviceMessage();
}
$lines[] = '';
foreach ($this->buildNoLlmShopProductLines($shopResults, $requestedProductRole) as $line) {
$lines[] = $line;
}
@@ -1499,7 +1507,7 @@ final readonly class AgentRunner
* @param ShopProductResult[] $shopResults
* @return string[]
*/
private function buildNoLlmShopProductLines(array $shopResults): array
private function buildNoLlmShopProductLines(array $shopResults, string $requestedProductRole): array
{
$maxResults = max(1, $this->agentRunnerConfig->getNoLlmFallbackMaxShopResults());
$lines = [];
@@ -1510,7 +1518,7 @@ final readonly class AgentRunner
continue;
}
$lines[] = $this->formatNoLlmShopProductLine($product, $index);
$lines[] = $this->formatNoLlmShopProductLine($product, $index, $requestedProductRole);
$index++;
if (count($lines) >= $maxResults) {
@@ -1525,9 +1533,10 @@ final readonly class AgentRunner
return $lines;
}
private function formatNoLlmShopProductLine(ShopProductResult $product, int $index): string
private function formatNoLlmShopProductLine(ShopProductResult $product, int $index, string $requestedProductRole): string
{
$parts = [];
$productRole = $this->resolveNoLlmShopProductRole($product);
$name = $this->normalizeOneLine($product->name);
$parts[] = $name !== '' ? $name : 'Unbenanntes Shop-Produkt';
@@ -1552,9 +1561,86 @@ final readonly class AgentRunner
$parts[] = 'URL: ' . $this->normalizeOneLine($product->url);
}
if ($requestedProductRole === 'main_device_or_system' && $productRole === 'accessory_or_consumable') {
$parts[] = 'Hinweis: Zubehör/Verbrauchsartikel; nicht als Messanlage/Gerät bestätigt';
}
return sprintf('%d. %s', $index, implode(' | ', $parts));
}
/**
* @param ShopProductResult[] $shopResults
*/
private function hasOnlyNoLlmAccessoryResultsForMainDeviceRequest(string $requestedProductRole, array $shopResults): bool
{
if ($requestedProductRole !== 'main_device_or_system') {
return false;
}
$seenProducts = 0;
foreach ($shopResults as $product) {
if (!$product instanceof ShopProductResult) {
continue;
}
$seenProducts++;
if ($this->resolveNoLlmShopProductRole($product) !== 'accessory_or_consumable') {
return false;
}
}
return $seenProducts > 0;
}
private function resolveNoLlmRequestedProductRole(string $prompt): string
{
$normalized = mb_strtolower($prompt, 'UTF-8');
if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords())) {
return 'accessory_or_consumable';
}
if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmMainDeviceRequestRoleKeywords())) {
return 'main_device_or_system';
}
return 'unknown';
}
private function resolveNoLlmShopProductRole(ShopProductResult $product): string
{
$normalized = mb_strtolower($this->normalizeOneLine(implode(' ', [
$product->name,
(string) $product->description,
(string) $product->customFields,
implode(' ', $product->highlights),
])), 'UTF-8');
if ($this->containsAnyConfiguredTerm($normalized, $this->agentRunnerConfig->getNoLlmAccessoryProductRoleKeywords())) {
return 'accessory_or_consumable';
}
return 'unknown';
}
/**
* @param string[] $terms
*/
private function containsAnyConfiguredTerm(string $haystack, array $terms): bool
{
foreach ($terms as $term) {
$term = mb_strtolower(trim($term), 'UTF-8');
if ($term !== '' && str_contains($haystack, $term)) {
return true;
}
}
return false;
}
/**
* @param string[] $sources

View File

@@ -52,6 +52,7 @@ final readonly class PromptBuilder
$hasKnowledge = $knowledgeChunks !== [] || $urlContent !== '';
$isTechnicalProductQuestion = $this->isLikelyTechnicalProductQuestion($prompt);
$asksForAccessoryOrBundle = $this->asksForAccessoryOrBundle($prompt);
$requestedProductRole = $this->resolveRequestedProductRole($prompt);
$reliabilityState = $this->resolveReliabilityState(
hasKnowledge: $hasKnowledge,
hasShopResults: $hasShopResults,
@@ -60,7 +61,11 @@ final readonly class PromptBuilder
);
$systemBlock = $this->buildSystemBlock();
$shopBlock = $this->buildShopBlock($shopResults, $swagFullOutPut);
$shopBlock = $this->buildShopBlock(
shopResults: $shopResults,
swagFullOutPut: $swagFullOutPut,
requestedProductRole: $requestedProductRole
);
$outputPriorityBlock = $this->buildOutputPriorityBlock(
hasShopResults: $hasShopResults,
isTechnicalProductQuestion: $isTechnicalProductQuestion
@@ -178,7 +183,7 @@ final readonly class PromptBuilder
* Shop data is the most current source for commercial details.
* It should not override technical matching logic.
*/
private function buildShopBlock(array $shopResults, ?string $swagFullOutPut): string
private function buildShopBlock(array $shopResults, ?string $swagFullOutPut, string $requestedProductRole): string
{
$parts = [];
@@ -208,7 +213,8 @@ final readonly class PromptBuilder
$lines[] = $this->buildShopProductEntry(
product: $product,
index: $i + 1,
isDetailed: $isDetailed
isDetailed: $isDetailed,
requestedProductRole: $requestedProductRole
);
}
@@ -464,12 +470,34 @@ final readonly class PromptBuilder
return $rules;
}
private function buildShopProductEntry(ShopProductResult $product, int $index, bool $isDetailed): string
{
private function buildShopProductEntry(
ShopProductResult $product,
int $index,
bool $isDetailed,
string $requestedProductRole
): string {
$productName = $this->normalizeBlockText($product->name);
$productRole = $this->resolveShopProductRole($product);
$roleCompatibility = $this->resolveShopProductRoleCompatibility($requestedProductRole, $productRole);
$isMainDeviceRequestAccessoryMismatch = $requestedProductRole === 'main_device_or_system'
&& $productRole === 'accessory_or_consumable';
$entryParts = [
"[{$index}] " . $this->normalizeBlockText($product->name),
sprintf($this->config->getShopRecordHeaderTemplate(), $index),
$this->config->getShopExactProductNameLabel() . ': ' . $productName,
$this->config->getShopRequestedRoleLabel() . ': ' . $requestedProductRole,
$this->config->getShopInferredRoleLabel() . ': ' . $productRole,
$this->config->getShopRoleCompatibilityLabel() . ': ' . $roleCompatibility,
];
foreach ($this->config->getShopAtomicRecordNoteLines() as $noteLine) {
$noteLine = $this->normalizeBlockText($noteLine);
if ($noteLine !== '') {
$entryParts[] = $noteLine;
}
}
if ($product->productNumber) {
$entryParts[] = $this->config->getShopProductNumberLabel() . ': '
. $this->normalizeBlockText($product->productNumber);
@@ -492,12 +520,16 @@ final readonly class PromptBuilder
: $this->config->getShopAvailabilityNoLabel());
}
foreach ($product->highlights as $highlight) {
$highlight = $this->normalizeBlockText((string) $highlight);
if (!$isMainDeviceRequestAccessoryMismatch) {
foreach ($product->highlights as $highlight) {
$highlight = $this->normalizeBlockText((string) $highlight);
if ($highlight !== '') {
$entryParts[] = $this->config->getShopHighlightPrefix() . $highlight;
if ($highlight !== '') {
$entryParts[] = $this->config->getShopHighlightPrefix() . $highlight;
}
}
} else {
$entryParts[] = $this->config->getShopRoleMismatchNotice();
}
if ($product->url) {
@@ -510,12 +542,12 @@ final readonly class PromptBuilder
. $this->normalizeBlockText($product->productImage);
}
if ($isDetailed && $product->description) {
if (!$isMainDeviceRequestAccessoryMismatch && $isDetailed && $product->description) {
$entryParts[] = $this->config->getShopDescriptionLabel() . ': '
. $this->normalizeBlockText($product->description);
}
if ($product->customFields) {
if (!$isMainDeviceRequestAccessoryMismatch && $product->customFields) {
$entryParts[] = $this->config->getShopMetaInformationLabel() . ': '
. $this->normalizeBlockText($product->customFields);
}
@@ -596,6 +628,81 @@ final readonly class PromptBuilder
return $value;
}
private function resolveRequestedProductRole(string $prompt): string
{
$normalized = mb_strtolower($prompt, 'UTF-8');
if ($this->containsAnyConfiguredTerm($normalized, $this->config->getAccessoryProductRoleKeywords())) {
return 'accessory_or_consumable';
}
if ($this->containsAnyConfiguredTerm($normalized, $this->config->getMainDeviceRequestRoleKeywords())) {
return 'main_device_or_system';
}
return 'unknown';
}
private function resolveShopProductRole(ShopProductResult $product): string
{
$text = mb_strtolower($this->implodeLines([
$product->name,
(string) $product->description,
(string) $product->customFields,
implode(' ', $product->highlights),
]), 'UTF-8');
// Accessory/consumable wins over broad device-family words such as "Testomat".
// Example: "Testomat Indikator TH2005" must stay an indicator, not a main device.
if ($this->containsAnyConfiguredTerm($text, $this->config->getAccessoryProductRoleKeywords())) {
return 'accessory_or_consumable';
}
if ($this->containsAnyConfiguredTerm($text, $this->config->getMainDeviceProductRoleKeywords())) {
return 'main_device_or_system';
}
return 'unknown';
}
private function resolveShopProductRoleCompatibility(string $requestedProductRole, string $shopProductRole): string
{
if ($requestedProductRole === 'unknown' || $shopProductRole === 'unknown') {
return 'unknown - do not use as primary product unless suitability is explicit in the same record';
}
if ($requestedProductRole === $shopProductRole) {
return 'compatible';
}
if ($requestedProductRole === 'main_device_or_system' && $shopProductRole === 'accessory_or_consumable') {
return 'not compatible - user asked for a main device/system, this shop record is accessory/consumable';
}
if ($requestedProductRole === 'accessory_or_consumable' && $shopProductRole === 'main_device_or_system') {
return 'not compatible - user asked for accessory/consumable, this shop record is a main device/system';
}
return 'unknown - keep separate from the main answer';
}
/**
* @param string[] $terms
*/
private function containsAnyConfiguredTerm(string $haystack, array $terms): bool
{
foreach ($terms as $term) {
$term = mb_strtolower(trim($term), 'UTF-8');
if ($term !== '' && str_contains($haystack, $term)) {
return true;
}
}
return false;
}
private function isLikelyTechnicalProductQuestion(string $prompt): bool
{
$normalized = mb_strtolower($prompt, 'UTF-8');