optimize technical truth

This commit is contained in:
team 1
2026-04-28 17:00:12 +02:00
parent 8d9f863143
commit bca015129c
5 changed files with 471 additions and 314 deletions

View File

@@ -52,7 +52,6 @@ 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,
@@ -61,11 +60,7 @@ final readonly class PromptBuilder
);
$systemBlock = $this->buildSystemBlock();
$shopBlock = $this->buildShopBlock(
shopResults: $shopResults,
swagFullOutPut: $swagFullOutPut,
requestedProductRole: $requestedProductRole
);
$shopBlock = $this->buildShopBlock($prompt, $shopResults, $swagFullOutPut);
$outputPriorityBlock = $this->buildOutputPriorityBlock(
hasShopResults: $hasShopResults,
isTechnicalProductQuestion: $isTechnicalProductQuestion
@@ -183,7 +178,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 $requestedProductRole): string
private function buildShopBlock(string $prompt, array $shopResults, ?string $swagFullOutPut): string
{
$parts = [];
@@ -214,7 +209,7 @@ final readonly class PromptBuilder
product: $product,
index: $i + 1,
isDetailed: $isDetailed,
requestedProductRole: $requestedProductRole
requestedRole: $this->resolveRequestedProductRole($prompt)
);
}
@@ -377,7 +372,7 @@ final readonly class PromptBuilder
}
$n = $i + 1;
$lines[] = "[{$n}] {$chunk}";
$lines[] = "[{$n}] RAG FACT RECORD\nRecord boundary: facts in this record must not be merged with accessory, indicator, reagent, price, URL, or product-number details from another record unless the same record explicitly connects them.\n" . $chunk;
}
if ($lines !== []) {
@@ -470,23 +465,18 @@ final readonly class PromptBuilder
return $rules;
}
private function buildShopProductEntry(
ShopProductResult $product,
int $index,
bool $isDetailed,
string $requestedProductRole
): string {
private function buildShopProductEntry(ShopProductResult $product, int $index, bool $isDetailed, string $requestedRole): 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';
$inferredRole = $this->resolveShopProductRole($product);
$roleCompatibility = $this->resolveShopRoleCompatibility($requestedRole, $inferredRole);
$entryParts = [
sprintf($this->config->getShopRecordHeaderTemplate(), $index),
$this->config->getShopExactProductNameLabel() . ': ' . $productName,
$this->config->getShopRequestedRoleLabel() . ': ' . $requestedProductRole,
$this->config->getShopInferredRoleLabel() . ': ' . $productRole,
$this->config->getShopRequestedRoleLabel() . ': ' . $requestedRole,
$this->config->getShopInferredRoleLabel() . ': ' . $inferredRole,
$this->config->getShopRoleCompatibilityLabel() . ': ' . $roleCompatibility,
];
@@ -498,56 +488,59 @@ final readonly class PromptBuilder
}
}
if ($product->productNumber) {
$suppressCommercialFields = $requestedRole === 'main_device'
&& $roleCompatibility === 'incompatible_accessory_for_main_device_request';
if ($suppressCommercialFields) {
$entryParts[] = $this->config->getShopRoleIncompatibleCommercialSuppressionNote();
}
if (!$suppressCommercialFields && $product->productNumber) {
$entryParts[] = $this->config->getShopProductNumberLabel() . ': '
. $this->normalizeBlockText($product->productNumber);
}
if ($product->manufacturer) {
if (!$suppressCommercialFields && $product->manufacturer) {
$entryParts[] = $this->config->getShopManufacturerLabel() . ': '
. $this->normalizeBlockText($product->manufacturer);
}
if ($product->price) {
if (!$suppressCommercialFields && $product->price) {
$entryParts[] = $this->config->getShopPriceLabel() . ': '
. $this->normalizeBlockText($product->price);
}
if ($product->available !== null) {
if (!$suppressCommercialFields && $product->available !== null) {
$entryParts[] = $this->config->getShopAvailabilityLabel() . ': '
. ($product->available
? $this->config->getShopAvailabilityYesLabel()
: $this->config->getShopAvailabilityNoLabel());
}
if (!$isMainDeviceRequestAccessoryMismatch) {
foreach ($product->highlights as $highlight) {
$highlight = $this->normalizeBlockText((string) $highlight);
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) {
if (!$suppressCommercialFields && $product->url) {
$entryParts[] = $this->config->getShopUrlLabel() . ': '
. $this->normalizeBlockText($product->url);
}
if ($product->productImage) {
if (!$suppressCommercialFields && $product->productImage) {
$entryParts[] = $this->config->getShopProductImageLabel() . ': '
. $this->normalizeBlockText($product->productImage);
}
if (!$isMainDeviceRequestAccessoryMismatch && $isDetailed && $product->description) {
if (!$suppressCommercialFields && $isDetailed && $product->description) {
$entryParts[] = $this->config->getShopDescriptionLabel() . ': '
. $this->normalizeBlockText($product->description);
}
if (!$isMainDeviceRequestAccessoryMismatch && $product->customFields) {
if (!$suppressCommercialFields && $product->customFields) {
$entryParts[] = $this->config->getShopMetaInformationLabel() . ': '
. $this->normalizeBlockText($product->customFields);
}
@@ -604,6 +597,123 @@ final readonly class PromptBuilder
return implode("\n\n", $filtered);
}
private function resolveRequestedProductRole(string $prompt): string
{
$normalized = mb_strtolower($prompt, 'UTF-8');
if ($this->containsAnyPromptKeyword($normalized, $this->config->getMainDeviceRequestRoleKeywords())) {
return 'main_device';
}
if ($this->containsAnyPromptKeyword($normalized, $this->config->getAccessoryProductRoleKeywords())) {
return 'accessory_or_consumable';
}
return 'unknown';
}
private function resolveShopProductRole(ShopProductResult $product): string
{
$primaryRole = $this->resolveShopPrimaryProductRole($product);
if ($primaryRole !== 'unknown') {
return $primaryRole;
}
$corpus = mb_strtolower(implode(' ', array_filter([
$product->name,
$product->productNumber,
$product->manufacturer,
implode(' ', $product->highlights),
$product->description,
$product->customFields,
$product->url,
])), 'UTF-8');
$isAccessory = $this->containsAnyPromptKeyword($corpus, $this->config->getAccessoryProductRoleKeywords());
$isMainDevice = $this->containsAnyPromptKeyword($corpus, $this->config->getMainDeviceProductRoleKeywords());
if ($isAccessory && !$isMainDevice) {
return 'accessory_or_consumable';
}
if ($isMainDevice && !$isAccessory) {
return 'main_device';
}
if ($isMainDevice && $isAccessory) {
return 'ambiguous_mixed_role';
}
return 'unknown';
}
private function resolveShopPrimaryProductRole(ShopProductResult $product): string
{
$primaryText = mb_strtolower(implode(' ', array_filter([
$product->name,
$product->url,
])), 'UTF-8');
if ($this->normalizeBlockText($primaryText) === '') {
return 'unknown';
}
$isAccessory = $this->containsAnyPromptKeyword($primaryText, $this->config->getAccessoryProductRoleKeywords());
$isMainDevice = $this->containsAnyPromptKeyword($primaryText, $this->config->getMainDeviceProductRoleKeywords());
if ($isAccessory && !$isMainDevice) {
return 'accessory_or_consumable';
}
if ($isMainDevice && !$isAccessory) {
return 'main_device';
}
if ($isMainDevice && $isAccessory) {
return 'ambiguous_mixed_role';
}
return 'unknown';
}
private function resolveShopRoleCompatibility(string $requestedRole, string $inferredRole): string
{
if ($requestedRole === 'unknown' || $inferredRole === 'unknown') {
return 'unknown';
}
if ($requestedRole === 'main_device' && $inferredRole === 'accessory_or_consumable') {
return 'incompatible_accessory_for_main_device_request';
}
if ($requestedRole === 'accessory_or_consumable' && $inferredRole === 'main_device') {
return 'incompatible_main_device_for_accessory_request';
}
if ($inferredRole === 'ambiguous_mixed_role') {
return 'ambiguous_keep_separate';
}
return 'compatible';
}
/**
* @param string[] $keywords
*/
private function containsAnyPromptKeyword(string $text, array $keywords): bool
{
foreach ($keywords as $keyword) {
$keyword = mb_strtolower($this->normalizeBlockText((string) $keyword), 'UTF-8');
if ($keyword !== '' && str_contains($text, $keyword)) {
return true;
}
}
return false;
}
private function normalizeNullableBlockText(?string $value): ?string
{
if ($value === null) {
@@ -628,81 +738,6 @@ 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');