optimize technical truth
This commit is contained in:
17
RETRIEX_ACCESSORY_INTENT_ROLE_PRECEDENCE_FIX_README.md
Normal file
17
RETRIEX_ACCESSORY_INTENT_ROLE_PRECEDENCE_FIX_README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# RetrieX Accessory Intent Role Precedence Fix
|
||||
|
||||
This patch fixes a side effect of the product-role and measurement-evidence guards.
|
||||
|
||||
Problem:
|
||||
- Queries such as "suche ph-indikatoren fuer messgeraete" contain both accessory wording (Indikatoren) and device wording (Messgeraete).
|
||||
- The previous role resolver prioritized main-device wording first, so the request was treated as a device suitability question.
|
||||
- This incorrectly triggered the pH measurement-capability guard and produced a no-match answer for pH-measuring Testomat devices.
|
||||
|
||||
Fix:
|
||||
- Explicit accessory/consumable intent now takes precedence over generic device wording.
|
||||
- Measurement-capability guards are skipped for accessory/consumable requests.
|
||||
- Shop-product measurement evidence lines are also skipped for accessory/consumable requests.
|
||||
- Additional indicator variants such as "indikatoren" and "ph-indikatoren" are added to the configurable accessory intent lists.
|
||||
|
||||
Scope:
|
||||
- No changes to SSE, jobs, retrieval, scoring, shop search, AgentRunner, or LLM timeouts.
|
||||
37
RETRIEX_MEASUREMENT_EVIDENCE_GUARD_FIX_README.md
Normal file
37
RETRIEX_MEASUREMENT_EVIDENCE_GUARD_FIX_README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# RetrieX Measurement Evidence Guard Fix
|
||||
|
||||
This patch tightens technical product-selection answers for measurement-parameter questions.
|
||||
|
||||
## Problem
|
||||
|
||||
The LLM could still merge similar water-treatment terms, for example treating `p-Wert` as `pH-Wert`, and then generate unsupported measurement ranges, application areas or recommendations.
|
||||
|
||||
## Change
|
||||
|
||||
The patch adds a generated `MEASUREMENT PARAMETER EVIDENCE CHECK` block to the final prompt when the user asks for configured measurement parameters such as pH, Redox/ORP or free chlorine.
|
||||
|
||||
For each relevant question, the PromptBuilder now:
|
||||
|
||||
- detects the requested measurement parameter from configurable YAML terms,
|
||||
- scans RAG/URL context and each shop product record for explicit positive evidence,
|
||||
- marks every shop record with a same-record evidence line,
|
||||
- tells the model not to recommend a product when no explicit evidence exists,
|
||||
- explicitly treats similar but non-equivalent terms such as `p-Wert`, `m-Wert`, `minus m-Wert`, `mmol/l` and alkalinity as insufficient for pH suitability.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `src/Agent/PromptBuilder.php`
|
||||
- `src/Config/PromptBuilderConfig.php`
|
||||
- `config/retriex/prompt.yaml`
|
||||
|
||||
## Intended behavior
|
||||
|
||||
For `suche einen testomat der ph messen kann`, if the provided RAG/shop records only contain `p-Wert` or related alkalinity values but no explicit `pH` / `pH-Wert` evidence in the same record, the answer should start with uncertainty, for example:
|
||||
|
||||
`Ich finde in den bereitgestellten Quellen keinen sicher belegten Testomat fuer pH-Messung.`
|
||||
|
||||
It may still list shop hits as commercial/search hits, but not as verified pH-suitable devices.
|
||||
|
||||
## Scope
|
||||
|
||||
This patch does not touch retrieval, scoring, shop search, AgentRunner, SSE or LLM settings.
|
||||
14
RETRIEX_PRODUCT_ROLE_GUARD_FIX_README.md
Normal file
14
RETRIEX_PRODUCT_ROLE_GUARD_FIX_README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# RetrieX Product Role Guard Fix
|
||||
|
||||
This patch tightens answer behavior for product-selection questions where the user asks for a main device, Testomat, analyzer, measuring device, system, or similar product role.
|
||||
|
||||
## Goal
|
||||
|
||||
If the user asks for a main device:
|
||||
|
||||
- accessories, indicators, reagents, spare parts, kits, sets, services and consumables must not be used as the main answer heading;
|
||||
- if only accessory/consumable hits are present, the answer must start with a no-match statement for the requested main device;
|
||||
- incompatible accessory records are marked as such in the prompt and their commercial fields are suppressed;
|
||||
- alternative devices must not be suggested unless suitability is explicitly proven in the provided sources.
|
||||
|
||||
The patch does not change retrieval, scoring, Shopware search, SSE, job lifecycle, or AgentRunner orchestration.
|
||||
@@ -48,6 +48,9 @@ parameters:
|
||||
- analyzer
|
||||
- system
|
||||
- testomat
|
||||
- testomaten
|
||||
- testoamt
|
||||
- testomate
|
||||
- pockettester
|
||||
accessory_product_keywords:
|
||||
- indikator
|
||||
|
||||
@@ -52,6 +52,7 @@ parameters:
|
||||
inferred_role_label: Inferred shop product role
|
||||
role_compatibility_label: Role compatibility with request
|
||||
role_incompatible_commercial_suppression_note: 'Commercial fields suppressed: this shop record is not a matching main-device result for the requested product role.'
|
||||
role_incompatible_record_note: 'Role guard: this is an accessory/consumable record for a main-device request. Do not use it as an answer heading, recommendation, or suitable main-device result.'
|
||||
technical_product_keyword_match_threshold: 2
|
||||
sections:
|
||||
system_label: SYSTEM
|
||||
@@ -63,6 +64,8 @@ parameters:
|
||||
response_format_label: RESPONSE FORMAT RULES
|
||||
language_rules_label: LANGUAGE RULES
|
||||
fact_grounding_rules_label: FACT GROUNDING RULES
|
||||
measurement_evidence_label: MEASUREMENT PARAMETER EVIDENCE CHECK
|
||||
product_role_guard_label: PRODUCT ROLE GUARD
|
||||
retrieved_knowledge_label: RETRIEVED KNOWLEDGE (primary for technical matching and factual explanation)
|
||||
url_content_label: CONTENT FROM URL (authoritative if user-provided)
|
||||
conversation_context:
|
||||
@@ -91,6 +94,11 @@ parameters:
|
||||
- system
|
||||
- monitor
|
||||
- controller
|
||||
- testomat
|
||||
- testomaten
|
||||
- testoamt
|
||||
- testomate
|
||||
- pockettester
|
||||
main_device_product_keywords:
|
||||
- messanlage
|
||||
- messanlagen
|
||||
@@ -107,9 +115,17 @@ parameters:
|
||||
- system
|
||||
- monitor
|
||||
- controller
|
||||
- testomat
|
||||
- testomaten
|
||||
- pockettester
|
||||
accessory_product_keywords:
|
||||
- indikator
|
||||
- indikatoren
|
||||
- ph-indikator
|
||||
- ph indikator
|
||||
- ph-indikatoren
|
||||
- ph indikatoren
|
||||
- indikatoren
|
||||
- indicator
|
||||
- reagenz
|
||||
- reagenzien
|
||||
@@ -135,6 +151,108 @@ parameters:
|
||||
- service set
|
||||
- serviceset
|
||||
- service-set
|
||||
- kalibrierlösung
|
||||
- kalibrierloesung
|
||||
- pufferlösung
|
||||
- pufferloesung
|
||||
- reinigungslösung
|
||||
- reinigungsloesung
|
||||
- flasche
|
||||
- bottle
|
||||
- 100 ml
|
||||
- 500 ml
|
||||
- 100ml
|
||||
- 500ml
|
||||
|
||||
main_device_answer_rules:
|
||||
- '- If the user asks for a Testomat, measuring device, analyzer, system, or main device, the answer must be anchored on a compatible main-device record or on a clear no-match statement.'
|
||||
- '- Accessories, indicators, reagents, calibration solutions, spare parts, kits, sets, services, or consumables must not be used as the main answer heading for a main-device request.'
|
||||
- '- If only accessories or consumables are available in the shop results, start with a negative main-device finding and mention that only accessory/consumable hits were found.'
|
||||
- '- Do not provide price, availability, product number, URL, or a recommendation for role-incompatible accessory records unless the user explicitly asks for that accessory.'
|
||||
- '- Do not propose alternative devices unless a provided source explicitly proves that exact device is suitable for the requested parameter.'
|
||||
no_main_device_match_template: 'No compatible main-device shop record is present for the requested main-device role. Accessory/consumable records must not be presented as the requested solution.'
|
||||
|
||||
measurement_evidence_guard:
|
||||
intro_rules:
|
||||
- '- This block is generated from the current user question and is stricter than broad product-selection wording.'
|
||||
- '- For measurement-parameter questions, technical suitability requires explicit positive evidence for the requested parameter in the same source record.'
|
||||
- '- Accessory/indicator requests are not main-device measurement-capability questions. If the user explicitly asks for indicators, reagents, accessories, or consumables, do not answer as if they asked for a measuring device that measures the parameter.'
|
||||
- '- Similar water-treatment parameters, abbreviations, units, product families, search queries, or ranking positions are not enough.'
|
||||
- '- For pH requests, pH operating ranges, pH values of reagents/indicators, pH transfer/output fields, and general pH mentions are not evidence that a device measures pH.'
|
||||
parameters:
|
||||
- id: ph
|
||||
label: pH / pH-Wert
|
||||
request_terms:
|
||||
- ph
|
||||
- pH
|
||||
- pH-Wert
|
||||
- ph-wert
|
||||
- ph wert
|
||||
positive_terms:
|
||||
- pH-Messung
|
||||
- pH Messung
|
||||
- pH-Messgeraet
|
||||
- pH Messgeraet
|
||||
- pH-Wert messen
|
||||
- pH Wert messen
|
||||
- pH-Werte messen
|
||||
- pH Werte messen
|
||||
- misst pH
|
||||
- misst den pH
|
||||
- misst pH-Wert
|
||||
- misst den pH-Wert
|
||||
- Messparameter pH
|
||||
- Messparameter pH-Wert
|
||||
- Messgroesse pH
|
||||
- Messgroesse pH-Wert
|
||||
non_equivalent_terms:
|
||||
- p-Wert
|
||||
- p Wert
|
||||
- m-Wert
|
||||
- minus m-Wert
|
||||
- Alkalität
|
||||
- Säurekapazität
|
||||
- mmol/l
|
||||
- pH-Bereich
|
||||
- Betriebsbereich
|
||||
- stoerungsfreier Betrieb
|
||||
- pH-Wert bei
|
||||
- Reagenz
|
||||
- Indikator
|
||||
- 4-20 mA Ausgang
|
||||
- pH-Wertuebertragung
|
||||
safe_no_evidence_answer_de: Ich finde in den bereitgestellten Quellen keinen sicher belegten Testomat, der pH als Messparameter misst.
|
||||
- id: redox
|
||||
label: Redox / ORP
|
||||
request_terms:
|
||||
- redox
|
||||
- orp
|
||||
- oxidations-reduktionspotential
|
||||
- oxidations reduktionspotential
|
||||
positive_terms:
|
||||
- Redox
|
||||
- ORP
|
||||
- Oxidations-Reduktionspotential
|
||||
- Oxidations Reduktionspotential
|
||||
non_equivalent_terms: []
|
||||
safe_no_evidence_answer_de: Ich finde in den bereitgestellten Quellen keinen sicher belegten Treffer für Redox-/ORP-Messung.
|
||||
- id: free_chlorine
|
||||
label: freies Chlor
|
||||
request_terms:
|
||||
- freies chlor
|
||||
- freiem chlor
|
||||
- freien chlor
|
||||
- free chlorine
|
||||
positive_terms:
|
||||
- freies Chlor
|
||||
- freiem Chlor
|
||||
- freien Chlor
|
||||
- free chlorine
|
||||
non_equivalent_terms:
|
||||
- Chlor gesamt
|
||||
- Gesamtchlor
|
||||
- total chlorine
|
||||
safe_no_evidence_answer_de: Ich finde in den bereitgestellten Quellen keinen sicher belegten Treffer für die Messung von freiem Chlor.
|
||||
|
||||
output_priority:
|
||||
rules:
|
||||
@@ -262,6 +380,9 @@ parameters:
|
||||
- '- Shop product names are authoritative for their own shop URL, product number, price, availability, image, description, and metadata.'
|
||||
- '- Do not rewrite a shop record heading with a similar device name from retrieved knowledge. If identities differ or are uncertain, separate the RAG device from the shop hit.'
|
||||
- '- If the user asks for a main device, measuring device, analyzer, system, or measuring installation, do not present an accessory, indicator, reagent, kit, set, consumable, or service item as the requested main solution.'
|
||||
- '- If a main-device request has no compatible main-device shop record, start with a no-match statement for the requested main device, not with the first accessory hit.'
|
||||
- '- Role-incompatible accessory records may only be mentioned in a short separate section such as "Nur Zubehörtreffer gefunden" and without price, URL, availability, product number, or recommendation wording.'
|
||||
- '- Do not suggest alternative devices for the requested parameter unless the same provided source explicitly proves the exact alternative device is suitable.'
|
||||
- '- If the only shop hit is role-incompatible with the requested product role, state that no matching main-device shop hit is available in the provided shop data; mention the incompatible hit only as a separate accessory/consumable hit if useful.'
|
||||
- '- If a SHOP PRODUCT RECORD says Commercial fields suppressed, do not output its price, availability, URL, product number, image, or metadata anywhere in the answer.'
|
||||
- '- Never write shop-hit lines such as price, availability, URL, product number, or Shop-Treffer below a RAG device unless the same exact SHOP PRODUCT RECORD names that device as the exact shop product.'
|
||||
@@ -274,6 +395,7 @@ parameters:
|
||||
technical_rules:
|
||||
- '- For technical product questions, answer primarily with explicitly stated facts.'
|
||||
- '- For measurement-parameter questions, do not treat similar or neighboring abbreviations as equivalent. In particular, p-Wert is not pH-Wert unless the source explicitly says pH or pH-Wert.'
|
||||
- '- For pH requests, do not present products as pH-capable when the source only states an allowable pH operating range, a pH value of a reagent/indicator solution, a 4-20 mA transfer/output reference, or a generic pH mention.'
|
||||
- '- Do not invent or infer measurement principles, methods, calibration functions, benefits, advantages, application areas, or alternative products from product family names, search rank, or shop query wording.'
|
||||
- '- Behave like a technical documentation assistant, not like a sales advisor.'
|
||||
- '- Keep interpretations minimal and do not generalize application areas beyond the provided sources.'
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ final class AgentRunnerConfig
|
||||
'analyzer',
|
||||
'system',
|
||||
'testomat',
|
||||
'testomaten',
|
||||
'testoamt',
|
||||
'testomate',
|
||||
'pockettester',
|
||||
];
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ final class PromptBuilderConfig
|
||||
'messgeräte', 'messgeraete', 'analysegerät', 'analysegeraet', 'analysegeräte',
|
||||
'analysegeraete', 'analysator', 'analysatoren', 'analyzer', 'gerät', 'geraet',
|
||||
'geräte', 'geraete', 'system', 'systeme', 'monitor', 'monitore', 'controller',
|
||||
'testomat', 'testomaten', 'testoamt', 'testomate', 'pockettester',
|
||||
];
|
||||
|
||||
private const MAIN_DEVICE_PRODUCT_ROLE_KEYWORDS = [
|
||||
@@ -19,6 +20,7 @@ final class PromptBuilderConfig
|
||||
'analysatoren', 'analyzer', 'online-analysator', 'online analysator',
|
||||
'online-analysegerät', 'online analysegeraet', 'gerät', 'geraet', 'geräte',
|
||||
'geraete', 'system', 'systeme', 'monitor', 'monitore', 'controller',
|
||||
'testomat', 'testomaten', 'pockettester',
|
||||
];
|
||||
|
||||
private const ACCESSORY_PRODUCT_ROLE_KEYWORDS = [
|
||||
@@ -27,6 +29,9 @@ final class PromptBuilderConfig
|
||||
'verbrauchsmaterial', 'consumable', 'nachfüll', 'nachfuell', 'refill',
|
||||
'lösung', 'loesung', 'solution', 'teststreifen', 'test strip', 'filter',
|
||||
'pumpenkopf', 'motorblock', 'service set', 'serviceset', 'service-set',
|
||||
'kalibrierlösung', 'kalibrierloesung', 'pufferlösung', 'pufferloesung',
|
||||
'reinigungslösung', 'reinigungsloesung', 'flasche', 'bottle', '100 ml', '500 ml',
|
||||
'100ml', '500ml',
|
||||
];
|
||||
|
||||
private const TECHNICAL_PRODUCT_KEYWORDS = [
|
||||
@@ -77,6 +82,66 @@ final class PromptBuilderConfig
|
||||
'chlormessung',
|
||||
];
|
||||
|
||||
private const MEASUREMENT_EVIDENCE_PARAMETERS = [
|
||||
[
|
||||
'id' => 'ph',
|
||||
'label' => 'pH / pH-Wert',
|
||||
'request_terms' => ['ph', 'pH', 'pH-Wert', 'ph-wert', 'ph wert'],
|
||||
'positive_terms' => [
|
||||
'pH-Messung',
|
||||
'pH Messung',
|
||||
'pH-Messgeraet',
|
||||
'pH Messgeraet',
|
||||
'pH-Wert messen',
|
||||
'pH Wert messen',
|
||||
'pH-Werte messen',
|
||||
'pH Werte messen',
|
||||
'misst pH',
|
||||
'misst den pH',
|
||||
'misst pH-Wert',
|
||||
'misst den pH-Wert',
|
||||
'Messparameter pH',
|
||||
'Messparameter pH-Wert',
|
||||
'Messgroesse pH',
|
||||
'Messgroesse pH-Wert',
|
||||
],
|
||||
'non_equivalent_terms' => [
|
||||
'p-Wert',
|
||||
'p Wert',
|
||||
'm-Wert',
|
||||
'minus m-Wert',
|
||||
'Alkalität',
|
||||
'Säurekapazität',
|
||||
'mmol/l',
|
||||
'pH-Bereich',
|
||||
'Betriebsbereich',
|
||||
'stoerungsfreier Betrieb',
|
||||
'pH-Wert bei',
|
||||
'Reagenz',
|
||||
'Indikator',
|
||||
'4-20 mA Ausgang',
|
||||
'pH-Wertuebertragung',
|
||||
],
|
||||
'safe_no_evidence_answer_de' => 'Ich finde in den bereitgestellten Quellen keinen sicher belegten Testomat, der pH als Messparameter misst.',
|
||||
],
|
||||
[
|
||||
'id' => 'redox',
|
||||
'label' => 'Redox / ORP',
|
||||
'request_terms' => ['redox', 'orp', 'oxidations-reduktionspotential', 'oxidations reduktionspotential'],
|
||||
'positive_terms' => ['Redox', 'ORP', 'Oxidations-Reduktionspotential', 'Oxidations Reduktionspotential'],
|
||||
'non_equivalent_terms' => [],
|
||||
'safe_no_evidence_answer_de' => 'Ich finde in den bereitgestellten Quellen keinen sicher belegten Treffer für Redox-/ORP-Messung.',
|
||||
],
|
||||
[
|
||||
'id' => 'free_chlorine',
|
||||
'label' => 'freies Chlor',
|
||||
'request_terms' => ['freies chlor', 'freiem chlor', 'freien chlor', 'free chlorine'],
|
||||
'positive_terms' => ['freies Chlor', 'freiem Chlor', 'freien Chlor', 'free chlorine'],
|
||||
'non_equivalent_terms' => ['Chlor gesamt', 'Gesamtchlor', 'total chlorine'],
|
||||
'safe_no_evidence_answer_de' => 'Ich finde in den bereitgestellten Quellen keinen sicher belegten Treffer für die Messung von freiem Chlor.',
|
||||
],
|
||||
];
|
||||
|
||||
private const ACCESSORY_REQUEST_KEYWORDS = [
|
||||
'passend',
|
||||
'passende',
|
||||
@@ -537,6 +602,7 @@ final class PromptBuilderConfig
|
||||
'- For product-selection questions, a shop result proves technical suitability only when the same SHOP PRODUCT RECORD explicitly states the requested measurement parameter, application, or compatibility. Search ranking, generated query terms, generic category matches, and similar wording are not proof.',
|
||||
'- If the requested parameter appears only in the generated shop query, metadata, unrelated highlights, or another product record, treat suitability as unverified and say that the shop hit requires technical verification.',
|
||||
'- Do not convert p-Wert, m-Wert, minus m-Wert, alkalinity, acid capacity, or other water-treatment parameters into pH or pH-Wert unless the same source explicitly says pH or pH-Wert.',
|
||||
'- For pH requests, do not treat pH operating ranges, reagent/indicator pH values, output-transfer references, or generic pH mentions as proof that a device measures pH.',
|
||||
'- When shop results are present and relevant, include current price and the actual URL if available.',
|
||||
'- If the shop data does not provide a positive price for a result, do not output any price for that result.',
|
||||
'- Do not let accessories, bundles, or service items override a technically better product match unless the user explicitly asks for them.',
|
||||
@@ -572,6 +638,7 @@ final class PromptBuilderConfig
|
||||
return $this->getStringList('fact_grounding.technical_rules', [
|
||||
'- For technical product questions, answer primarily with explicitly stated facts.',
|
||||
'- For measurement-parameter questions, do not treat similar or neighboring abbreviations as equivalent. In particular, p-Wert is not pH-Wert unless the source explicitly says pH or pH-Wert.',
|
||||
'- For pH requests, do not present products as pH-capable when the source only states an allowable pH operating range, a pH value of a reagent/indicator solution, a 4-20 mA transfer/output reference, or a generic pH mention.',
|
||||
'- Do not invent or infer measurement principles, methods, calibration functions, benefits, advantages, application areas, or alternative products from product family names, search rank, or shop query wording.',
|
||||
'- Behave like a technical documentation assistant, not like a sales advisor.',
|
||||
'- Keep interpretations minimal and do not generalize application areas beyond the provided sources.',
|
||||
@@ -700,6 +767,41 @@ final class PromptBuilderConfig
|
||||
);
|
||||
}
|
||||
|
||||
public function getProductRoleGuardSectionLabel(): string
|
||||
{
|
||||
return $this->getString('sections.product_role_guard_label', 'PRODUCT ROLE GUARD');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getProductRoleGuardMainDeviceRules(): array
|
||||
{
|
||||
return $this->getStringList('role_guard.main_device_answer_rules', [
|
||||
'- If the user asks for a Testomat, measuring device, analyzer, system, or main device, the answer must be anchored on a compatible main-device record or on a clear no-match statement.',
|
||||
'- Accessories, indicators, reagents, calibration solutions, spare parts, kits, sets, services, or consumables must not be used as the main answer heading for a main-device request.',
|
||||
'- If only accessories or consumables are available in the shop results, start with a negative main-device finding and mention that only accessory/consumable hits were found.',
|
||||
'- Do not provide price, availability, product number, URL, or a recommendation for role-incompatible accessory records unless the user explicitly asks for that accessory.',
|
||||
'- Do not propose alternative devices unless a provided source explicitly proves that exact device is suitable for the requested parameter.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getProductRoleGuardNoMainDeviceTemplate(): string
|
||||
{
|
||||
return $this->getString(
|
||||
'role_guard.no_main_device_match_template',
|
||||
'No compatible main-device shop record is present for the requested main-device role. Accessory/consumable records must not be presented as the requested solution.'
|
||||
);
|
||||
}
|
||||
|
||||
public function getProductRoleGuardIncompatibleRecordNote(): string
|
||||
{
|
||||
return $this->getString(
|
||||
'shop_results.fields.role_incompatible_record_note',
|
||||
'Role guard: this is an accessory/consumable record for a main-device request. Do not use it as an answer heading, recommendation, or suitable main-device result.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
@@ -746,6 +848,88 @@ final class PromptBuilderConfig
|
||||
);
|
||||
}
|
||||
|
||||
public function getMeasurementEvidenceSectionLabel(): string
|
||||
{
|
||||
return $this->getString('sections.measurement_evidence_label', 'MEASUREMENT PARAMETER EVIDENCE CHECK');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getMeasurementEvidenceIntroRules(): array
|
||||
{
|
||||
return $this->getStringList('measurement_evidence_guard.intro_rules', [
|
||||
'- This block is generated from the current user question and is stricter than broad product-selection wording.',
|
||||
'- For measurement-parameter questions, technical suitability requires explicit positive evidence for the requested parameter in the same source record.',
|
||||
'- Similar water-treatment parameters, abbreviations, units, product families, search queries, or ranking positions are not enough.',
|
||||
'- For pH requests, pH operating ranges, pH values of reagents/indicators, pH transfer/output fields, and general pH mentions are not evidence that a device measures pH.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getMeasurementEvidenceParameters(): array
|
||||
{
|
||||
$value = $this->getValue('measurement_evidence_guard.parameters', self::MEASUREMENT_EVIDENCE_PARAMETERS);
|
||||
|
||||
if (!is_array($value)) {
|
||||
return self::MEASUREMENT_EVIDENCE_PARAMETERS;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($value as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = isset($item['id']) && is_scalar($item['id']) ? trim((string) $item['id']) : '';
|
||||
$label = isset($item['label']) && is_scalar($item['label']) ? trim((string) $item['label']) : '';
|
||||
|
||||
if ($id === '' || $label === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'id' => $id,
|
||||
'label' => $label,
|
||||
'request_terms' => $this->normalizeMixedStringList($item['request_terms'] ?? []),
|
||||
'positive_terms' => $this->normalizeMixedStringList($item['positive_terms'] ?? []),
|
||||
'non_equivalent_terms' => $this->normalizeMixedStringList($item['non_equivalent_terms'] ?? []),
|
||||
'safe_no_evidence_answer_de' => isset($item['safe_no_evidence_answer_de']) && is_scalar($item['safe_no_evidence_answer_de'])
|
||||
? trim((string) $item['safe_no_evidence_answer_de'])
|
||||
: '',
|
||||
];
|
||||
}
|
||||
|
||||
return $out !== [] ? $out : self::MEASUREMENT_EVIDENCE_PARAMETERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function normalizeMixedStringList(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($value as $item) {
|
||||
if (!is_scalar($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = trim((string) $item);
|
||||
if ($item !== '' && !in_array($item, $out, true)) {
|
||||
$out[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function getTechnicalProductModelPattern(): string
|
||||
{
|
||||
return $this->getString('technical_product_model_pattern', '/\b[\p{L}]{2,}\s?\d{2,5}\b/u');
|
||||
|
||||
Reference in New Issue
Block a user