optimize technical truth

This commit is contained in:
team 1
2026-04-29 14:39:12 +02:00
parent 123d5d4020
commit 06f192b28a
8 changed files with 740 additions and 20 deletions

View File

@@ -61,6 +61,13 @@ final readonly class PromptBuilder
$systemBlock = $this->buildSystemBlock();
$shopBlock = $this->buildShopBlock($prompt, $shopResults, $swagFullOutPut);
$productRoleGuardBlock = $this->buildProductRoleGuardBlock($prompt, $shopResults);
$measurementEvidenceBlock = $this->buildMeasurementEvidenceBlock(
prompt: $prompt,
knowledgeChunks: $knowledgeChunks,
urlContent: $urlContent,
shopResults: $shopResults
);
$outputPriorityBlock = $this->buildOutputPriorityBlock(
hasShopResults: $hasShopResults,
isTechnicalProductQuestion: $isTechnicalProductQuestion
@@ -88,6 +95,8 @@ final readonly class PromptBuilder
$fixedPrompt = $this->implodeBlocks([
$systemBlock,
$shopBlock,
$productRoleGuardBlock,
$measurementEvidenceBlock,
$outputPriorityBlock,
$fallbackEscalationBlock,
$responseFormatBlock,
@@ -104,6 +113,8 @@ final readonly class PromptBuilder
return $this->implodeBlocks([
$systemBlock,
$shopBlock,
$productRoleGuardBlock,
$measurementEvidenceBlock,
$outputPriorityBlock,
$fallbackEscalationBlock,
$responseFormatBlock,
@@ -202,6 +213,8 @@ final readonly class PromptBuilder
$totalCount = count($normalizedShopResults);
$limitedShopResults = array_slice($normalizedShopResults, 0, $this->config->getMaxShopResultsInPrompt());
$isDetailed = count($limitedShopResults) <= $this->config->getDetailedShopResultsMaxCount();
$requestedRole = $this->resolveRequestedProductRole($prompt);
$measurementGuard = $this->resolveRequestedMeasurementGuard($prompt);
$lines = [];
foreach ($limitedShopResults as $i => $product) {
@@ -209,7 +222,8 @@ final readonly class PromptBuilder
product: $product,
index: $i + 1,
isDetailed: $isDetailed,
requestedRole: $this->resolveRequestedProductRole($prompt)
requestedRole: $requestedRole,
measurementGuard: $measurementGuard
);
}
@@ -465,8 +479,13 @@ final readonly class PromptBuilder
return $rules;
}
private function buildShopProductEntry(ShopProductResult $product, int $index, bool $isDetailed, string $requestedRole): string
{
private function buildShopProductEntry(
ShopProductResult $product,
int $index,
bool $isDetailed,
string $requestedRole,
?array $measurementGuard = null
): string {
$productName = $this->normalizeBlockText($product->name);
$inferredRole = $this->resolveShopProductRole($product);
@@ -488,11 +507,19 @@ final readonly class PromptBuilder
}
}
$measurementEvidenceLine = $this->buildShopMeasurementEvidenceLine($product, $measurementGuard, $requestedRole);
if ($measurementEvidenceLine !== '') {
$entryParts[] = $measurementEvidenceLine;
}
$suppressCommercialFields = $requestedRole === 'main_device'
&& $roleCompatibility === 'incompatible_accessory_for_main_device_request';
if ($suppressCommercialFields) {
$entryParts[] = $this->config->getShopRoleIncompatibleCommercialSuppressionNote();
$entryParts[] = $this->config->getProductRoleGuardIncompatibleRecordNote();
return implode("\n", $entryParts);
}
if (!$suppressCommercialFields && $product->productNumber) {
@@ -548,6 +575,325 @@ final readonly class PromptBuilder
return implode("\n", $entryParts);
}
/**
* @param ShopProductResult[] $shopResults
*/
private function buildProductRoleGuardBlock(string $prompt, array $shopResults): string
{
$requestedRole = $this->resolveRequestedProductRole($prompt);
if ($requestedRole !== 'main_device') {
return '';
}
$records = array_values(array_filter(
$shopResults,
static fn(mixed $product): bool => $product instanceof ShopProductResult
));
if ($records === []) {
return '';
}
$compatibleMainDeviceRecords = [];
$incompatibleAccessoryRecords = [];
$unknownRecords = [];
foreach ($records as $index => $product) {
$inferredRole = $this->resolveShopProductRole($product);
$roleCompatibility = $this->resolveShopRoleCompatibility($requestedRole, $inferredRole);
$name = $this->normalizeBlockText($product->name);
$label = sprintf('record %d%s', $index + 1, $name !== '' ? ' (' . $name . ')' : '');
if ($roleCompatibility === 'compatible' && $inferredRole === 'main_device') {
$compatibleMainDeviceRecords[] = $label;
continue;
}
if ($roleCompatibility === 'incompatible_accessory_for_main_device_request') {
$incompatibleAccessoryRecords[] = $label;
continue;
}
$unknownRecords[] = $label . ' role=' . $inferredRole . ' compatibility=' . $roleCompatibility;
}
$rules = $this->config->getProductRoleGuardMainDeviceRules();
$rules[] = '- Requested product role resolved from the user question: main_device.';
$rules[] = '- Compatible main-device shop records: ' . ($compatibleMainDeviceRecords !== [] ? implode('; ', $compatibleMainDeviceRecords) : 'none') . '.';
$rules[] = '- Role-incompatible accessory/consumable shop records: ' . ($incompatibleAccessoryRecords !== [] ? implode('; ', $incompatibleAccessoryRecords) : 'none') . '.';
if ($unknownRecords !== []) {
$rules[] = '- Unknown or ambiguous shop records must be kept separate and must not be upgraded into a main-device recommendation: ' . implode('; ', $unknownRecords) . '.';
}
if ($compatibleMainDeviceRecords === [] && $incompatibleAccessoryRecords !== []) {
$rules[] = '- Mandatory answer behavior: ' . $this->config->getProductRoleGuardNoMainDeviceTemplate();
$rules[] = '- Start with the no-match finding for the requested main device. Do not start with an accessory product name.';
$rules[] = '- If mentioning incompatible shop hits, use a short separate section like "Nur Zubehörtreffer gefunden" and do not include price, availability, URL, product number, or recommendation wording for those records.';
}
return $this->buildRuleBlock(
$this->config->getProductRoleGuardSectionLabel(),
$rules
);
}
/**
* @param string[] $knowledgeChunks
* @param ShopProductResult[] $shopResults
*/
private function buildMeasurementEvidenceBlock(
string $prompt,
array $knowledgeChunks,
string $urlContent,
array $shopResults
): string {
$requestedRole = $this->resolveRequestedProductRole($prompt);
if ($requestedRole === 'accessory_or_consumable') {
return '';
}
$guard = $this->resolveRequestedMeasurementGuard($prompt);
if ($guard === null) {
return '';
}
$positiveTerms = $this->extractMeasurementGuardStringList($guard, 'positive_terms');
$nonEquivalentTerms = $this->extractMeasurementGuardStringList($guard, 'non_equivalent_terms');
$label = $this->normalizeBlockText((string) ($guard['label'] ?? 'requested measurement parameter'));
$safeNoEvidenceAnswer = $this->normalizeBlockText((string) ($guard['safe_no_evidence_answer_de'] ?? ''));
$knowledgeText = $this->normalizeBlockText(implode("\n\n", array_map('strval', $knowledgeChunks)) . "\n\n" . $urlContent);
$knowledgeHasEvidence = $this->containsMeasurementPositiveEvidence($knowledgeText, $positiveTerms);
$knowledgeHasOnlyWeakMention = !$knowledgeHasEvidence
&& $this->containsAnyMeasurementWeakMention($knowledgeText, $guard);
$shopEvidenceLines = [];
$shopHasEvidence = false;
foreach (array_values($shopResults) as $index => $product) {
if (!$product instanceof ShopProductResult) {
continue;
}
$hasEvidence = $this->shopProductHasMeasurementEvidence($product, $positiveTerms);
$productName = $this->normalizeBlockText($product->name);
if ($hasEvidence) {
$shopHasEvidence = true;
$shopEvidenceLines[] = sprintf(
'- Shop record %d (%s): explicit positive evidence for %s is present in this same record.',
$index + 1,
$productName !== '' ? $productName : 'unnamed product',
$label
);
}
}
if ($shopEvidenceLines === []) {
$shopEvidenceLines[] = sprintf(
'- No shop product record shown to the model contains explicit positive evidence for %s in the same record.',
$label
);
}
$rules = $this->config->getMeasurementEvidenceIntroRules();
$rules[] = '- User requested measurement parameter: ' . $label . '.';
$rules[] = '- Positive evidence terms that count for this request: ' . implode(', ', $positiveTerms) . '.';
if ($nonEquivalentTerms !== []) {
$rules[] = '- Terms that must NOT be treated as equivalent positive evidence: ' . implode(', ', $nonEquivalentTerms) . '.';
}
$rules[] = '- RAG/URL evidence scan for this exact measurement capability: ' . ($knowledgeHasEvidence ? 'explicit positive capability evidence found.' : 'no explicit positive capability evidence found.');
if ($knowledgeHasOnlyWeakMention) {
$rules[] = '- RAG/URL weak mention scan: the parameter is mentioned, but only as a weak/non-capability mention. Do not use this as suitability evidence.';
}
$rules = array_merge($rules, $shopEvidenceLines);
if (!$knowledgeHasEvidence && !$shopHasEvidence) {
$rules[] = '- Mandatory answer behavior: do not recommend a product as suitable for this measurement parameter.';
if ($safeNoEvidenceAnswer !== '') {
$rules[] = '- Start the answer with this meaning in the user language: ' . $safeNoEvidenceAnswer;
}
$rules[] = '- You may list exact shop hits only as commercial/search hits under a heading such as "Shop-Treffer (technische Eignung nicht sicher belegt)".';
}
$rules[] = '- Do not output measurement ranges, methods, application areas, advantages, or alternative suitable models unless the same source record contains explicit positive capability evidence for the requested measurement parameter.';
$rules[] = '- Do not list products as relevant just because the requested parameter appears in an operating range, reagent/indicator property, output-transfer field, metadata, or generic mention.';
$rules[] = '- The generated shop search query, search intent, ranking position, and user question are not factual evidence for product suitability.';
return $this->buildRuleBlock(
$this->config->getMeasurementEvidenceSectionLabel(),
$rules
);
}
private function buildShopMeasurementEvidenceLine(ShopProductResult $product, ?array $guard, string $requestedRole = 'unknown'): string
{
if ($guard === null || $requestedRole === 'accessory_or_consumable') {
return '';
}
$positiveTerms = $this->extractMeasurementGuardStringList($guard, 'positive_terms');
$label = $this->normalizeBlockText((string) ($guard['label'] ?? 'requested measurement parameter'));
if ($positiveTerms === []) {
return '';
}
if ($this->shopProductHasMeasurementEvidence($product, $positiveTerms)) {
return sprintf(
'Requested measurement evidence: explicit positive evidence for %s is present in this same SHOP PRODUCT RECORD.',
$label
);
}
return sprintf(
'Requested measurement evidence: no explicit positive evidence for %s is present in this SHOP PRODUCT RECORD. Do not present this record as technically suitable for that measurement parameter.',
$label
);
}
private function resolveRequestedMeasurementGuard(string $prompt): ?array
{
$normalizedPrompt = $this->normalizeForMeasurementMatching($prompt);
foreach ($this->config->getMeasurementEvidenceParameters() as $parameter) {
$requestTerms = $this->extractMeasurementGuardStringList($parameter, 'request_terms');
foreach ($requestTerms as $term) {
if ($this->containsMeasurementTerm($normalizedPrompt, $term)) {
return $parameter;
}
}
}
return null;
}
/**
* @return string[]
*/
private function extractMeasurementGuardStringList(array $guard, string $key): array
{
$value = $guard[$key] ?? [];
if (!is_array($value)) {
return [];
}
$out = [];
foreach ($value as $item) {
if (!is_scalar($item)) {
continue;
}
$item = $this->normalizeBlockText((string) $item);
if ($item !== '' && !in_array($item, $out, true)) {
$out[] = $item;
}
}
return $out;
}
/**
* @param string[] $positiveTerms
*/
private function shopProductHasMeasurementEvidence(ShopProductResult $product, array $positiveTerms): bool
{
return $this->containsMeasurementPositiveEvidence(
$this->buildShopProductEvidenceText($product),
$positiveTerms
);
}
private function buildShopProductEvidenceText(ShopProductResult $product): string
{
return $this->normalizeBlockText(implode(' ', array_filter([
$product->name,
$product->productNumber,
$product->manufacturer,
implode(' ', array_map('strval', $product->highlights)),
$product->description,
$product->customFields,
$product->url,
], static fn($value): bool => is_scalar($value) && trim((string) $value) !== '')));
}
/**
* @param string[] $positiveTerms
*/
private function containsMeasurementPositiveEvidence(string $text, array $positiveTerms): bool
{
$normalizedText = $this->normalizeForMeasurementMatching($text);
foreach ($positiveTerms as $term) {
if ($this->containsMeasurementTerm($normalizedText, $term)) {
return true;
}
}
return false;
}
/**
* Returns true when a measurement parameter is mentioned only as request wording or as a known non-equivalent/weak term.
* This is used for prompt diagnostics and must not be treated as positive suitability evidence.
*/
private function containsAnyMeasurementWeakMention(string $text, array $guard): bool
{
$weakTerms = array_merge(
$this->extractMeasurementGuardStringList($guard, 'request_terms'),
$this->extractMeasurementGuardStringList($guard, 'non_equivalent_terms')
);
if ($weakTerms === []) {
return false;
}
$normalizedText = $this->normalizeForMeasurementMatching($text);
foreach ($weakTerms as $term) {
if ($this->containsMeasurementTerm($normalizedText, $term)) {
return true;
}
}
return false;
}
private function containsMeasurementTerm(string $normalizedText, string $term): bool
{
$normalizedTerm = $this->normalizeForMeasurementMatching($term);
if ($normalizedText === '' || $normalizedTerm === '') {
return false;
}
if (preg_match('/[\p{L}\p{N}]/u', $normalizedTerm) !== 1) {
return str_contains($normalizedText, $normalizedTerm);
}
$pattern = '/(?<![\p{L}\p{N}])' . preg_quote($normalizedTerm, '/') . '(?![\p{L}\p{N}])/u';
return preg_match($pattern, $normalizedText) === 1;
}
private function normalizeForMeasurementMatching(string $value): string
{
$value = mb_strtolower($this->normalizeBlockText($value), 'UTF-8');
$value = str_replace(['', '', '', '', '—'], '-', $value);
$value = preg_replace('/<[^>]+>/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
return trim($value);
}
/**
* @param string[] $rules
*/
@@ -600,15 +946,17 @@ final readonly class PromptBuilder
private function resolveRequestedProductRole(string $prompt): string
{
$normalized = mb_strtolower($prompt, 'UTF-8');
$asksForAccessory = $this->containsAnyPromptKeyword($normalized, $this->config->getAccessoryRequestKeywords())
|| $this->containsAnyPromptKeyword($normalized, $this->config->getAccessoryProductRoleKeywords());
if ($asksForAccessory) {
return 'accessory_or_consumable';
}
if ($this->containsAnyPromptKeyword($normalized, $this->config->getMainDeviceRequestRoleKeywords())) {
return 'main_device';
}
if ($this->containsAnyPromptKeyword($normalized, $this->config->getAccessoryProductRoleKeywords())) {
return 'accessory_or_consumable';
}
return 'unknown';
}
@@ -633,18 +981,14 @@ final readonly class PromptBuilder
$isAccessory = $this->containsAnyPromptKeyword($corpus, $this->config->getAccessoryProductRoleKeywords());
$isMainDevice = $this->containsAnyPromptKeyword($corpus, $this->config->getMainDeviceProductRoleKeywords());
if ($isAccessory && !$isMainDevice) {
if ($isAccessory) {
return 'accessory_or_consumable';
}
if ($isMainDevice && !$isAccessory) {
if ($isMainDevice) {
return 'main_device';
}
if ($isMainDevice && $isAccessory) {
return 'ambiguous_mixed_role';
}
return 'unknown';
}
@@ -662,18 +1006,14 @@ final readonly class PromptBuilder
$isAccessory = $this->containsAnyPromptKeyword($primaryText, $this->config->getAccessoryProductRoleKeywords());
$isMainDevice = $this->containsAnyPromptKeyword($primaryText, $this->config->getMainDeviceProductRoleKeywords());
if ($isAccessory && !$isMainDevice) {
if ($isAccessory) {
return 'accessory_or_consumable';
}
if ($isMainDevice && !$isAccessory) {
if ($isMainDevice) {
return 'main_device';
}
if ($isMainDevice && $isAccessory) {
return 'ambiguous_mixed_role';
}
return 'unknown';
}
@@ -773,4 +1113,4 @@ final readonly class PromptBuilder
{
return max($min, min($max, $value));
}
}
}