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) {
'answer' => ' '.$msg,
'answer' => $msg,
'err' => sprintf($this->agentRunnerConfig->getErrorHtmlTemplate(), $msg),
'think' => sprintf($this->agentRunnerConfig->getThinkHtmlTemplate(), $msg),
'info' => sprintf($this->agentRunnerConfig->getInfoHtmlTemplate(), $msg),
'debug' => sprintf(
$this->agentRunnerConfig->getDebugHtmlTemplate(),
htmlspecialchars($msg, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
)
),
default => $msg,
};
}
}

View File

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

View File

@@ -188,6 +188,14 @@ final readonly class SearchRepairService
$modelCandidates = $this->extractModelCandidates($combinedText);
$accessoryCandidates = $this->extractAccessoryCandidates($combinedText);
$requestedAccessoryCodes = $this->extractRequestedAccessoryCodes($prompt . "\n" . $primaryQuery);
if ($requestedAccessoryCodes !== []) {
$accessoryCandidates = $this->filterAccessoryCandidatesByRequestedCodes(
accessoryCandidates: $accessoryCandidates,
requestedCodes: $requestedAccessoryCodes
);
}
$topPrimaryName = $primaryShopResults[0]->name ?? '';
$topPrimaryProductNumber = $primaryShopResults[0]->productNumber ?? null;
@@ -195,6 +203,18 @@ final readonly class SearchRepairService
$queries = [];
$queries = array_merge(
$queries,
$this->buildFocusedModelAccessoryQueries(
prompt: $prompt,
primaryQuery: $primaryQuery,
knowledgeText: $knowledgeText,
modelCandidates: $modelCandidates,
accessoryCandidates: $accessoryCandidates,
requestedAccessoryCodes: $requestedAccessoryCodes
)
);
if ($topPrimaryPhrase !== '' && $this->containsModelLikePhrase($topPrimaryPhrase)) {
$queries[] = $topPrimaryPhrase;
} elseif ($topPrimaryName !== '' && $this->containsModelLikePhrase($topPrimaryName)) {
@@ -293,6 +313,7 @@ final readonly class SearchRepairService
foreach ($matches[1] ?? [] as $candidate) {
$candidate = $this->sanitizeQuery($candidate);
$candidate = $this->reduceToSpecificModelCandidate($candidate);
if ($candidate === '') {
continue;
@@ -377,6 +398,245 @@ final readonly class SearchRepairService
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
{
return preg_match($this->config->getAccessoryOrBundlePattern(), $prompt) === 1;

View File

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

View File

@@ -36,6 +36,16 @@ final class CommerceQueryParserConfig
'welches ist am besten',
'alternative',
'alternativen',
'welche',
'welcher',
'welches',
'welchen',
'sind',
'ist',
'geeignet',
'geeigent',
'verfügbarkeit',
'verfuegbarkeit',
];
}
@@ -74,6 +84,22 @@ final class CommerceQueryParserConfig
'mir',
'mal',
'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.',
'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.',
'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.',
'- 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 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 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.',
'- 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 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.',
@@ -348,7 +352,9 @@ final class PromptBuilderConfig
'- 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/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 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 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.',
@@ -456,6 +462,16 @@ final class PromptBuilderConfig
'relay',
'indikator',
'indicator',
'grenzwert',
'threshold',
'messbereich',
'measurement range',
'minimaler',
'minimum',
'resthärte',
'resthaerte',
'°dh',
'dh',
'spannung',
'voltage',
'strom',