fix 4 optimize systemMsg and model default config values

This commit is contained in:
team 1
2026-04-24 19:52:35 +02:00
parent 2fd7977693
commit 9312562dd8
6 changed files with 313 additions and 9 deletions

View File

@@ -778,14 +778,15 @@ final readonly class AgentRunner
} }
return match ($type) { return match ($type) {
'answer' => ' '.$msg, 'answer' => $msg,
'err' => sprintf($this->agentRunnerConfig->getErrorHtmlTemplate(), $msg), 'err' => sprintf($this->agentRunnerConfig->getErrorHtmlTemplate(), $msg),
'think' => sprintf($this->agentRunnerConfig->getThinkHtmlTemplate(), $msg), 'think' => sprintf($this->agentRunnerConfig->getThinkHtmlTemplate(), $msg),
'info' => sprintf($this->agentRunnerConfig->getInfoHtmlTemplate(), $msg), 'info' => sprintf($this->agentRunnerConfig->getInfoHtmlTemplate(), $msg),
'debug' => sprintf( 'debug' => sprintf(
$this->agentRunnerConfig->getDebugHtmlTemplate(), $this->agentRunnerConfig->getDebugHtmlTemplate(),
htmlspecialchars($msg, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') htmlspecialchars($msg, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
) ),
default => $msg,
}; };
} }
} }

View File

@@ -205,13 +205,8 @@ final readonly class CommerceQueryParser
) ?? $text; ) ?? $text;
} }
if ($brand !== null && $brand !== '' && !$this->isBrandPartOfModelPhrase($prompt, $brand)) { // Keep known brand terms in the shop search text because the Store API
$text = preg_replace( // request does not add a separate manufacturer filter.
$this->config->buildExactTokenRemovalPattern($brand),
' ',
$text
) ?? $text;
}
if ($priceMin !== null || $priceMax !== null) { if ($priceMin !== null || $priceMax !== null) {
foreach ($this->config->getPriceRemovalPatterns($this->intentConfig) as $pattern) { foreach ($this->config->getPriceRemovalPatterns($this->intentConfig) as $pattern) {

View File

@@ -188,6 +188,14 @@ final readonly class SearchRepairService
$modelCandidates = $this->extractModelCandidates($combinedText); $modelCandidates = $this->extractModelCandidates($combinedText);
$accessoryCandidates = $this->extractAccessoryCandidates($combinedText); $accessoryCandidates = $this->extractAccessoryCandidates($combinedText);
$requestedAccessoryCodes = $this->extractRequestedAccessoryCodes($prompt . "\n" . $primaryQuery);
if ($requestedAccessoryCodes !== []) {
$accessoryCandidates = $this->filterAccessoryCandidatesByRequestedCodes(
accessoryCandidates: $accessoryCandidates,
requestedCodes: $requestedAccessoryCodes
);
}
$topPrimaryName = $primaryShopResults[0]->name ?? ''; $topPrimaryName = $primaryShopResults[0]->name ?? '';
$topPrimaryProductNumber = $primaryShopResults[0]->productNumber ?? null; $topPrimaryProductNumber = $primaryShopResults[0]->productNumber ?? null;
@@ -195,6 +203,18 @@ final readonly class SearchRepairService
$queries = []; $queries = [];
$queries = array_merge(
$queries,
$this->buildFocusedModelAccessoryQueries(
prompt: $prompt,
primaryQuery: $primaryQuery,
knowledgeText: $knowledgeText,
modelCandidates: $modelCandidates,
accessoryCandidates: $accessoryCandidates,
requestedAccessoryCodes: $requestedAccessoryCodes
)
);
if ($topPrimaryPhrase !== '' && $this->containsModelLikePhrase($topPrimaryPhrase)) { if ($topPrimaryPhrase !== '' && $this->containsModelLikePhrase($topPrimaryPhrase)) {
$queries[] = $topPrimaryPhrase; $queries[] = $topPrimaryPhrase;
} elseif ($topPrimaryName !== '' && $this->containsModelLikePhrase($topPrimaryName)) { } elseif ($topPrimaryName !== '' && $this->containsModelLikePhrase($topPrimaryName)) {
@@ -293,6 +313,7 @@ final readonly class SearchRepairService
foreach ($matches[1] ?? [] as $candidate) { foreach ($matches[1] ?? [] as $candidate) {
$candidate = $this->sanitizeQuery($candidate); $candidate = $this->sanitizeQuery($candidate);
$candidate = $this->reduceToSpecificModelCandidate($candidate);
if ($candidate === '') { if ($candidate === '') {
continue; continue;
@@ -377,6 +398,245 @@ final readonly class SearchRepairService
return $score; return $score;
} }
/**
* @param string[] $modelCandidates
* @param string[] $accessoryCandidates
* @param string[] $requestedAccessoryCodes
* @return string[]
*/
private function buildFocusedModelAccessoryQueries(
string $prompt,
string $primaryQuery,
string $knowledgeText,
array $modelCandidates,
array $accessoryCandidates,
array $requestedAccessoryCodes
): array {
if ($requestedAccessoryCodes === []) {
return [];
}
$queries = [];
$models = $this->filterModelCandidatesByRequestedAccessoryCodes(
prompt: $prompt . "\n" . $primaryQuery,
knowledgeText: $knowledgeText,
modelCandidates: $modelCandidates,
requestedCodes: $requestedAccessoryCodes
);
if ($models === []) {
return [];
}
$accessories = $accessoryCandidates;
if ($accessories === []) {
foreach ($requestedAccessoryCodes as $code) {
$accessories[] = 'Indikator ' . $code;
}
}
foreach ($models as $model) {
foreach ($accessories as $accessory) {
if (!$this->candidateMatchesRequestedAccessoryCodes($accessory, $requestedAccessoryCodes)) {
continue;
}
$queries[] = trim($model . ' ' . $accessory);
}
}
return array_values(array_unique(array_filter(
array_map(fn(string $query): string => $this->sanitizeQuery($query), $queries),
static fn(string $query): bool => $query !== ''
)));
}
/**
* @return string[]
*/
private function extractRequestedAccessoryCodes(string $text): array
{
$codes = [];
if (preg_match_all('/\b(?:indikator|indicator|reagenz|reagent)\s*([A-Za-z]{0,3}\s*\d{1,5}[A-Za-z0-9\-]*)\b/iu', $text, $matches) !== false) {
foreach ($matches[1] ?? [] as $code) {
$normalized = $this->normalizeAccessoryCode((string) $code);
if ($normalized !== '') {
$codes[$normalized] = $normalized;
}
}
}
return array_values($codes);
}
/**
* @param string[] $accessoryCandidates
* @param string[] $requestedCodes
* @return string[]
*/
private function filterAccessoryCandidatesByRequestedCodes(array $accessoryCandidates, array $requestedCodes): array
{
return array_values(array_filter(
$accessoryCandidates,
fn(string $candidate): bool => $this->candidateMatchesRequestedAccessoryCodes($candidate, $requestedCodes)
));
}
/**
* @param string[] $modelCandidates
* @param string[] $requestedCodes
* @return string[]
*/
private function filterModelCandidatesByRequestedAccessoryCodes(
string $prompt,
string $knowledgeText,
array $modelCandidates,
array $requestedCodes
): array {
$models = [];
$normalizedPrompt = $this->normalizeForRepairMatching($prompt);
foreach ($modelCandidates as $candidate) {
$candidate = $this->reduceToSpecificModelCandidate($candidate);
if ($candidate === '') {
continue;
}
$normalizedCandidate = $this->normalizeForRepairMatching($candidate);
$isPromptAnchored = $normalizedCandidate !== '' && str_contains($normalizedPrompt, $normalizedCandidate);
foreach ($requestedCodes as $code) {
if ($isPromptAnchored || $this->modelAppearsNearAccessoryCode($knowledgeText, $candidate, $code)) {
$models[$candidate] = $candidate;
break;
}
}
}
return array_values($models);
}
private function candidateMatchesRequestedAccessoryCodes(string $candidate, array $requestedCodes): bool
{
$normalizedCandidate = $this->normalizeForRepairMatching($candidate);
$compactCandidate = preg_replace('/\s+/u', '', $normalizedCandidate) ?? $normalizedCandidate;
foreach ($requestedCodes as $code) {
$normalizedCode = $this->normalizeAccessoryCode($code);
if ($normalizedCode === '') {
continue;
}
$pattern = '/\b' . preg_quote($normalizedCode, '/') . '\b/u';
if (preg_match($pattern, $normalizedCandidate) === 1 || preg_match($pattern, $compactCandidate) === 1) {
return true;
}
}
return false;
}
private function modelAppearsNearAccessoryCode(string $knowledgeText, string $model, string $code): bool
{
$normalizedText = $this->normalizeForRepairMatching($knowledgeText);
$normalizedModel = $this->normalizeForRepairMatching($model);
$normalizedCode = $this->normalizeAccessoryCode($code);
if ($normalizedText === '' || $normalizedModel === '' || $normalizedCode === '') {
return false;
}
$modelPositions = $this->findNeedlePositions($normalizedText, $normalizedModel);
if ($modelPositions === []) {
return false;
}
$codeNeedles = [
'indikator ' . $normalizedCode,
'indicator ' . $normalizedCode,
'indikatortyp ' . $normalizedCode,
$normalizedCode,
];
foreach ($codeNeedles as $needle) {
foreach ($this->findNeedlePositions($normalizedText, $needle) as $codePos) {
foreach ($modelPositions as $modelPos) {
if (abs($codePos - $modelPos) <= 1600) {
return true;
}
}
}
}
return false;
}
/**
* @return int[]
*/
private function findNeedlePositions(string $haystack, string $needle): array
{
if ($haystack === '' || $needle === '') {
return [];
}
$positions = [];
$offset = 0;
while (($position = mb_strpos($haystack, $needle, $offset, 'UTF-8')) !== false) {
$positions[] = $position;
$offset = $position + max(1, strlen($needle));
}
return $positions;
}
private function reduceToSpecificModelCandidate(string $candidate): string
{
$candidate = $this->sanitizeQuery($candidate);
if ($candidate === '') {
return '';
}
$patterns = [
'/\b(Testomat(?:®)?\s+(?:\d{3,4}|EVO(?:\s+[A-ZÄÖÜ]{1,8})?|ECO(?:[-\s]?(?:PLUS|C))?|DUO(?:\s+\d{3,4})?|LAB(?:\s+[A-ZÄÖÜ]{1,8})?))\b/iu',
'/\b(Horiba\s+LAQUA\s+[A-Z0-9\-]+)\b/iu',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $candidate, $matches) === 1) {
return $this->sanitizeQuery((string) ($matches[1] ?? ''));
}
}
if (preg_match('/\b(?:indikator|indicator|reagenz|reagent|verfuegbarkeit|verfügbarkeit|shop)\b/iu', $candidate) === 1) {
return '';
}
return $candidate;
}
private function normalizeAccessoryCode(string $code): string
{
$code = $this->normalizeForRepairMatching($code);
$code = preg_replace('/\s+/u', '', $code) ?? $code;
return trim($code);
}
private function normalizeForRepairMatching(string $value): string
{
$value = mb_strtolower(trim($value), 'UTF-8');
$value = str_replace('®', '', $value);
$value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
$value = preg_replace($this->config->getWhitespaceCollapsePattern(), ' ', $value) ?? $value;
return trim($value);
}
private function asksForBundleOrAccessory(string $prompt): bool private function asksForBundleOrAccessory(string $prompt): bool
{ {
return preg_match($this->config->getAccessoryOrBundlePattern(), $prompt) === 1; return preg_match($this->config->getAccessoryOrBundlePattern(), $prompt) === 1;

View File

@@ -32,6 +32,11 @@ final class CommerceIntentConfig
'messgeraet', 'messgeraet',
'analysator', 'analysator',
'analyzer', 'analyzer',
'puffer',
'kalibrierpuffer',
'kalibrierlösung',
'kalibrierloesung',
'kalibrierung',
]; ];
} }
@@ -46,6 +51,7 @@ final class CommerceIntentConfig
'besser', 'besser',
'besten', 'besten',
'geeignet', 'geeignet',
'geeigent',
'empfiehl', 'empfiehl',
'empfehl', 'empfehl',
]; ];

View File

@@ -36,6 +36,16 @@ final class CommerceQueryParserConfig
'welches ist am besten', 'welches ist am besten',
'alternative', 'alternative',
'alternativen', 'alternativen',
'welche',
'welcher',
'welches',
'welchen',
'sind',
'ist',
'geeignet',
'geeigent',
'verfügbarkeit',
'verfuegbarkeit',
]; ];
} }
@@ -74,6 +84,22 @@ final class CommerceQueryParserConfig
'mir', 'mir',
'mal', 'mal',
'von', 'von',
'im',
'in',
'für',
'fuer',
'welche',
'welcher',
'welches',
'welchen',
'sind',
'ist',
'geeignet',
'geeigent',
'verfügbarkeit',
'verfuegbarkeit',
'prüfe',
'pruefe',
]; ];
} }

View File

@@ -155,6 +155,8 @@ final class PromptBuilderConfig
'Do not infer undocumented technical specifications from shop data.', 'Do not infer undocumented technical specifications from shop data.',
'Commercial fields from shop data may only be assigned to a product if the shop item clearly matches the same product identity.', 'Commercial fields from shop data may only be assigned to a product if the shop item clearly matches the same product identity.',
'Do not merge a device identified in retrieved knowledge with price, URL, product number, or availability from a different shop item such as a reagent, accessory, kit, consumable, or service item.', 'Do not merge a device identified in retrieved knowledge with price, URL, product number, or availability from a different shop item such as a reagent, accessory, kit, consumable, or service item.',
'If a shop result has no price field, do not state a price for it.',
'Never interpret a missing price or a zero price as free, kostenlos, gratis, or available for 0.00 EUR.',
]; ];
} }
@@ -214,6 +216,7 @@ final class PromptBuilderConfig
'- Only use shop price, URL, product number, or availability for the main product when the shop result clearly matches that same main product.', '- Only use shop price, URL, product number, or availability for the main product when the shop result clearly matches that same main product.',
'- If the matching shop item appears to be an accessory, reagent, consumable, set, or kit, keep it separate and do not present its commercial fields as the main device.', '- If the matching shop item appears to be an accessory, reagent, consumable, set, or kit, keep it separate and do not present its commercial fields as the main device.',
'- If the commercial match is uncertain, say that commercial details for the main product are not clearly available in the provided shop results.', '- If the commercial match is uncertain, say that commercial details for the main product are not clearly available in the provided shop results.',
'- If no price is shown for a shop item, omit the price instead of writing 0,00 €, free, kostenlos, or a guessed price.',
]; ];
} }
@@ -307,6 +310,7 @@ final class PromptBuilderConfig
'- Use shop data as highest priority only for current commercial fields: price, availability, URL, and current shop-visible naming.', '- Use shop data as highest priority only for current commercial fields: price, availability, URL, and current shop-visible naming.',
'- Use retrieved knowledge as highest priority for technical matching, thresholds, measurement principles, and technical explanation.', '- Use retrieved knowledge as highest priority for technical matching, thresholds, measurement principles, and technical explanation.',
'- When shop results are present and relevant, include current price and the actual URL if available.', '- 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.', '- Do not let accessories, bundles, or service items override a technically better product match unless the user explicitly asks for them.',
'- Do not call accessories, indicators, reagents, kits, sets, or consumables a device, measuring device, or main product unless the source explicitly says so.', '- Do not call accessories, indicators, reagents, kits, sets, or consumables a device, measuring device, or main product unless the source explicitly says so.',
'- Do not claim that an accessory is required, necessary, used for calibration, or sets the measurement range unless this is explicitly stated in the provided sources.', '- Do not claim that an accessory is required, necessary, used for calibration, or sets the measurement range unless this is explicitly stated in the provided sources.',
@@ -348,7 +352,9 @@ final class PromptBuilderConfig
'- If the source names an indicator and threshold, reproduce that exactly without extrapolation.', '- If the source names an indicator and threshold, reproduce that exactly without extrapolation.',
'- For lowest, highest, smallest, largest, minimum, maximum, Grenzwert, Messbereich or Aufloesung questions, first identify the exact numeric extreme from the retrieved knowledge and answer that value directly.', '- For lowest, highest, smallest, largest, minimum, maximum, Grenzwert, Messbereich or Aufloesung questions, first identify the exact numeric extreme from the retrieved knowledge and answer that value directly.',
'- For lowest/highest/minimum/maximum questions, answer only the requested extreme unless the user explicitly asks for a comparison or alternatives.', '- For lowest/highest/minimum/maximum questions, answer only the requested extreme unless the user explicitly asks for a comparison or alternatives.',
'- For direct numeric lookup questions such as which device measures a given threshold, answer with the exact matching device/value pair first and avoid advisory caveats.',
'- Do not add the runner-up product, second-lowest value, or adjacent range unless the user asks for it.', '- Do not add the runner-up product, second-lowest value, or adjacent range unless the user asks for it.',
'- Do not add calibration, accuracy, pretreatment, temperature, or application notes unless those exact notes are requested and explicitly present in the retrieved source.',
'- For follow-up questions such as "which indicator measures that value", first resolve the referenced value/device, then use the retrieved source entry that explicitly connects value, device and indicator.', '- For follow-up questions such as "which indicator measures that value", first resolve the referenced value/device, then use the retrieved source entry that explicitly connects value, device and indicator.',
'- For numeric extreme questions, do not combine a value, device name, indicator name, range or product variant from different chunks unless the same retrieved entry explicitly connects them.', '- For numeric extreme questions, do not combine a value, device name, indicator name, range or product variant from different chunks unless the same retrieved entry explicitly connects them.',
'- If several devices or indicators are present, keep each device-indicator-range assignment separate and do not transfer an indicator from one product to another.', '- If several devices or indicators are present, keep each device-indicator-range assignment separate and do not transfer an indicator from one product to another.',
@@ -456,6 +462,16 @@ final class PromptBuilderConfig
'relay', 'relay',
'indikator', 'indikator',
'indicator', 'indicator',
'grenzwert',
'threshold',
'messbereich',
'measurement range',
'minimaler',
'minimum',
'resthärte',
'resthaerte',
'°dh',
'dh',
'spannung', 'spannung',
'voltage', 'voltage',
'strom', 'strom',