diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index 60a3755..e0bdbaa 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -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, }; } } \ No newline at end of file diff --git a/src/Commerce/CommerceQueryParser.php b/src/Commerce/CommerceQueryParser.php index 87bf752..87fc6f3 100644 --- a/src/Commerce/CommerceQueryParser.php +++ b/src/Commerce/CommerceQueryParser.php @@ -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) { diff --git a/src/Commerce/SearchRepairService.php b/src/Commerce/SearchRepairService.php index 5dec354..4733844 100644 --- a/src/Commerce/SearchRepairService.php +++ b/src/Commerce/SearchRepairService.php @@ -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; diff --git a/src/Config/CommerceIntentConfig.php b/src/Config/CommerceIntentConfig.php index 2cb278c..05f4f4c 100644 --- a/src/Config/CommerceIntentConfig.php +++ b/src/Config/CommerceIntentConfig.php @@ -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', ]; diff --git a/src/Config/CommerceQueryParserConfig.php b/src/Config/CommerceQueryParserConfig.php index 5cd76aa..ab26683 100644 --- a/src/Config/CommerceQueryParserConfig.php +++ b/src/Config/CommerceQueryParserConfig.php @@ -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', ]; } diff --git a/src/Config/PromptBuilderConfig.php b/src/Config/PromptBuilderConfig.php index 6ee2bbc..1de65a0 100644 --- a/src/Config/PromptBuilderConfig.php +++ b/src/Config/PromptBuilderConfig.php @@ -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',