optimize technical truth
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user