From a0e0ec67d159ebe6d2c81cdbbac17f2a9027a6fd Mon Sep 17 00:00:00 2001 From: team 1 Date: Sun, 19 Apr 2026 17:17:50 +0200 Subject: [PATCH] optimize weigths by rag and shop --- src/Agent/PromptBuilder.php | 22 +- src/Commerce/ShopSearchService.php | 408 ++++++++-------------------- src/Config/CommerceIntentConfig.php | 20 +- src/Config/PromptBuilderConfig.php | 5 +- src/Config/ShopServiceConfig.php | 78 ++++++ src/Intent/CommerceIntentLite.php | 84 +++++- 6 files changed, 315 insertions(+), 302 deletions(-) create mode 100644 src/Config/ShopServiceConfig.php diff --git a/src/Agent/PromptBuilder.php b/src/Agent/PromptBuilder.php index d2de794..39c11b4 100644 --- a/src/Agent/PromptBuilder.php +++ b/src/Agent/PromptBuilder.php @@ -228,7 +228,9 @@ final readonly class PromptBuilder "Use these results as the primary source for current price, availability, URL, and current shop-visible product naming.\n" . "If retrieved documents conflict with shop data on price, availability, URL, or current naming, prefer the shop data.\n" . "Output real URL values exactly as provided in the shop results. Do not replace them with placeholders, link labels, or product names.\n" . - "Do not infer undocumented technical specifications from shop data."; + "Do not infer undocumented technical specifications from shop data.\n" . + "Commercial fields from shop data may only be assigned to a product if the shop item clearly matches the same product identity.\n" . + "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 ($totalCount > count($limitedShopResults)) { $header .= "\n" . @@ -268,16 +270,27 @@ final readonly class PromptBuilder "- Use short, clean paragraphs or short labeled sections.", "- Do not use persuasive or promotional wording.", "- Do not repeat the same fact in slightly different wording.", + "- Never mention brands, manufacturers, model names, or product families that do not appear in the provided shop results, retrieved knowledge, URL content, or conversation context.", + "- If no suitable product is explicitly grounded in the provided sources, say that plainly instead of inventing alternatives.", + "- Do not generate external alternative lists, vendor suggestions, or purchase recommendations unless they are explicitly present in the provided sources.", + "- Do not combine technical identity from one source with commercial fields from a different product.", + "- Product number, price, availability, and URL must belong to the same explicitly grounded product.", ]; if ($hasShopResults) { $rules[] = "- If a product is identified, prefer this structure per product: product name, product number, price, availability, URL, then only the most relevant technical facts."; $rules[] = "- Keep price, availability, and URL on separate lines when they are present."; + $rules[] = "- Only use shop price, URL, product number, or availability for the main product when the shop result clearly matches that same main product."; + $rules[] = "- 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."; + $rules[] = "- If the commercial match is uncertain, say that commercial details for the main product are not clearly available in the provided shop results."; + } else { + $rules[] = "- If no shop results are present, do not compensate by inventing external products or external manufacturers."; } if ($isTechnicalProductQuestion) { $rules[] = "- Write like technical documentation: precise, neutral, and source-close."; $rules[] = "- Prefer exact values, ranges, thresholds, compatibility notes, and application areas over general explanation."; + $rules[] = "- If the sources only support a negative finding, output only that negative finding and do not add speculative alternatives."; } if ($this->asksForAccessoryOrBundle($prompt)) { @@ -407,6 +420,8 @@ final readonly class PromptBuilder "- Clearly separate explicit facts from inferences.", "- If a conclusion goes beyond the source wording, label it exactly as 'Inference:'.", "- If a sentence cannot be traced to the provided sources, do not write it.", + "- Never mention external manufacturers, external brands, or external products unless they are explicitly present in the provided sources.", + "- If the sources do not identify a suitable product, do not invent one.", ]; if ($hasShopResults) { @@ -417,9 +432,13 @@ final readonly class PromptBuilder "- 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.", + "- Do not assign the product number, price, URL, or availability of a reagent, accessory, kit, set, consumable, or service item to a device identified in retrieved knowledge.", + "- Only use commercial fields for the main product when the shop item and the technically identified product clearly refer to the same product identity.", + "- If the shop match is ambiguous, keep the technical identification and commercial details separate.", ]); } else { $rules[] = "- Use retrieved knowledge as authoritative for factual answers."; + $rules[] = "- If no shop results are present, do not compensate with external recommendations or external product suggestions."; } if ($isTechnicalProductQuestion) { @@ -440,6 +459,7 @@ final readonly class PromptBuilder "- If the source states only a threshold function, do not expand it into broader control logic.", "- If a detail is not explicitly stated in the provided sources, say so plainly.", "- Prefer short, source-close sentences over explanatory expansion.", + "- If the sources only support that a product family is not suitable, output only that unsuitability and stop there.", ]); } diff --git a/src/Commerce/ShopSearchService.php b/src/Commerce/ShopSearchService.php index f015d63..393f535 100644 --- a/src/Commerce/ShopSearchService.php +++ b/src/Commerce/ShopSearchService.php @@ -1,11 +1,10 @@ -enabled) { - $this->logger->info('Shop search skipped because commerce search is disabled', [ - 'commerceIntent' => $commerceIntent, - ]); - + $this->logger->info('Shop search skipped because commerce search is disabled', ['commerceIntent' => $commerceIntent,]); return []; } - - $primaryQuery = $this->queryParser->parse( - $originalPrompt, - $commerceIntent, - $commerceHistoryContext - ); - - $this->logger->info('Shop search started', [ - 'commerceIntent' => $commerceIntent, - 'originalPrompt' => $originalPrompt, - 'normalizedPrompt' => $primaryQuery->normalizedPrompt, - 'searchText' => $primaryQuery->searchText, - 'brand' => $primaryQuery->brand, - 'sizes' => $primaryQuery->sizes, - 'priceMin' => $primaryQuery->priceMin, - 'priceMax' => $primaryQuery->priceMax, - 'hasCommerceHistoryContext' => $commerceHistoryContext !== '', - 'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext), - 'criteriaLimit' => $this->maxResults, - ]); - + $primaryQuery = $this->queryParser->parse($originalPrompt, $commerceIntent, $commerceHistoryContext); + $this->logger->info('Shop search started', ['commerceIntent' => $commerceIntent, 'originalPrompt' => $originalPrompt, 'normalizedPrompt' => $primaryQuery->normalizedPrompt, 'searchText' => $primaryQuery->searchText, 'brand' => $primaryQuery->brand, 'sizes' => $primaryQuery->sizes, 'priceMin' => $primaryQuery->priceMin, 'priceMax' => $primaryQuery->priceMax, 'hasCommerceHistoryContext' => $commerceHistoryContext !== '', 'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext), 'criteriaLimit' => $this->maxResults,]); $rankedProducts = $this->executeSearch($primaryQuery, $commerceIntent, $originalPrompt, true); - if ($rankedProducts === [] && $commerceHistoryContext !== '') { - $fallbackQuery = $this->queryParser->parse( - $originalPrompt, - $commerceIntent, - '' - ); - - $this->logger->info('Shop search retry without commerce history context', [ - 'commerceIntent' => $commerceIntent, - 'originalPrompt' => $originalPrompt, - 'normalizedPrompt' => $fallbackQuery->normalizedPrompt, - 'searchText' => $fallbackQuery->searchText, - 'brand' => $fallbackQuery->brand, - 'sizes' => $fallbackQuery->sizes, - 'priceMin' => $fallbackQuery->priceMin, - 'priceMax' => $fallbackQuery->priceMax, - ]); - + $fallbackQuery = $this->queryParser->parse($originalPrompt, $commerceIntent, ''); + $this->logger->info('Shop search retry without commerce history context', ['commerceIntent' => $commerceIntent, 'originalPrompt' => $originalPrompt, 'normalizedPrompt' => $fallbackQuery->normalizedPrompt, 'searchText' => $fallbackQuery->searchText, 'brand' => $fallbackQuery->brand, 'sizes' => $fallbackQuery->sizes, 'priceMin' => $fallbackQuery->priceMin, 'priceMax' => $fallbackQuery->priceMax,]); $rankedProducts = $this->executeSearch($fallbackQuery, $commerceIntent, $originalPrompt, false); } - - $this->logger->info('Shop search finished', [ - 'commerceIntent' => $commerceIntent, - 'originalPrompt' => $originalPrompt, - 'rankedProductsCount' => count($rankedProducts), - 'topProducts' => array_map( - static fn(ShopProductResult $product): array => [ - 'name' => $product->name, - 'productNumber' => $product->productNumber, - 'manufacturer' => $product->manufacturer, - 'available' => $product->available, - ], - array_slice($rankedProducts, 0, 3) - ), - ]); - + $this->logger->info('Shop search finished', ['commerceIntent' => $commerceIntent, 'originalPrompt' => $originalPrompt, 'rankedProductsCount' => count($rankedProducts), 'topProducts' => array_map(static fn(ShopProductResult $product): array => ['name' => $product->name, 'productNumber' => $product->productNumber, 'manufacturer' => $product->manufacturer, 'available' => $product->available,], array_slice($rankedProducts, 0, 3)),]); return $rankedProducts; } - /** - * @return ShopProductResult[] - */ - private function executeSearch( - CommerceSearchQuery $query, - string $commerceIntent, - string $originalPrompt, - bool $usesHistoryContext - ): array { + /** * @return ShopProductResult[] */ + private function executeSearch(CommerceSearchQuery $query, string $commerceIntent, string $originalPrompt, bool $usesHistoryContext): array + { $criteria = $this->criteriaBuilder->build($query, $this->maxResults); - try { $response = $this->storeApiClient->searchProducts($criteria); - } catch ( - ClientExceptionInterface - | RedirectionExceptionInterface - | ServerExceptionInterface - | TransportExceptionInterface $e - ) { - $this->logger->warning('Shop search request failed', [ - 'commerceIntent' => $commerceIntent, - 'originalPrompt' => $originalPrompt, - 'normalizedPrompt' => $query->normalizedPrompt, - 'searchText' => $query->searchText, - 'brand' => $query->brand, - 'sizes' => $query->sizes, - 'priceMin' => $query->priceMin, - 'priceMax' => $query->priceMax, - 'usesHistoryContext' => $usesHistoryContext, - 'criteria' => $criteria, - 'exceptionClass' => $e::class, - 'exceptionMessage' => $e->getMessage(), - ]); - + } catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $e) { + $this->logger->warning('Shop search request failed', ['commerceIntent' => $commerceIntent, 'originalPrompt' => $originalPrompt, 'normalizedPrompt' => $query->normalizedPrompt, 'searchText' => $query->searchText, 'brand' => $query->brand, 'sizes' => $query->sizes, 'priceMin' => $query->priceMin, 'priceMax' => $query->priceMax, 'usesHistoryContext' => $usesHistoryContext, 'criteria' => $criteria, 'exceptionClass' => $e::class, 'exceptionMessage' => $e->getMessage(),]); return []; } - $mappedProducts = $this->mapProducts($response); $rankedProducts = $this->rerankProducts($mappedProducts, $query); - - $this->logger->info('Shop search request finished', [ - 'commerceIntent' => $commerceIntent, - 'originalPrompt' => $originalPrompt, - 'normalizedPrompt' => $query->normalizedPrompt, - 'searchText' => $query->searchText, - 'brand' => $query->brand, - 'sizes' => $query->sizes, - 'priceMin' => $query->priceMin, - 'priceMax' => $query->priceMax, - 'usesHistoryContext' => $usesHistoryContext, - 'rawElementsCount' => is_array($response['elements'] ?? null) ? count($response['elements']) : 0, - 'mappedProductsCount' => count($mappedProducts), - 'rankedProductsCount' => count($rankedProducts), - 'topProducts' => array_map( - static fn(ShopProductResult $product): array => [ - 'name' => $product->name, - 'productNumber' => $product->productNumber, - 'manufacturer' => $product->manufacturer, - 'available' => $product->available, - ], - array_slice($rankedProducts, 0, 3) - ), - ]); - + $this->logger->info('Shop search request finished', ['commerceIntent' => $commerceIntent, 'originalPrompt' => $originalPrompt, 'normalizedPrompt' => $query->normalizedPrompt, 'searchText' => $query->searchText, 'brand' => $query->brand, 'sizes' => $query->sizes, 'priceMin' => $query->priceMin, 'priceMax' => $query->priceMax, 'usesHistoryContext' => $usesHistoryContext, 'rawElementsCount' => is_array($response['elements'] ?? null) ? count($response['elements']) : 0, 'mappedProductsCount' => count($mappedProducts), 'rankedProductsCount' => count($rankedProducts), 'topProducts' => array_map(static fn(ShopProductResult $product): array => ['name' => $product->name, 'productNumber' => $product->productNumber, 'manufacturer' => $product->manufacturer, 'available' => $product->available,], array_slice($rankedProducts, 0, 3)),]); return $rankedProducts; } - /** - * @return ShopProductResult[] - */ + /** * @return ShopProductResult[] */ private function mapProducts(array $response): array { $elements = $response['elements'] ?? []; if (!is_array($elements)) { return []; } - $results = []; - foreach ($elements as $row) { if (!is_array($row)) { continue; } - $relativeUrl = $this->extractUrl($row); - - $results[] = new ShopProductResult( - id: (string) ($row['id'] ?? ''), - name: trim((string) ($row['translated']['name'] ?? '')), - productNumber: isset($row['productNumber']) ? (string) $row['productNumber'] : null, - manufacturer: $this->extractManufacturer($row), - price: $this->extractPrice($row), - available: isset($row['available']) ? (bool) $row['available'] : null, - url: $this->buildAbsoluteUrl($relativeUrl), - highlights: $this->extractHighlights($row), - description: $this->cleanUpDescription($row), - productImage: $row['cover']['media']['thumbnails'][0]['url'] ?? 'no-image', - customFields: $this->getRelevantCustomFields($row['customFields'] ?? []) - ); + $results[] = new ShopProductResult(id: (string)($row['id'] ?? ''), name: trim((string)($row['translated']['name'] ?? '')), productNumber: isset($row['productNumber']) ? (string)$row['productNumber'] : null, manufacturer: $this->extractManufacturer($row), price: $this->extractPrice($row), available: isset($row['available']) ? (bool)$row['available'] : null, url: $this->buildAbsoluteUrl($relativeUrl), highlights: $this->extractHighlights($row), description: $this->cleanUpDescription($row), productImage: $row['cover']['media']['thumbnails'][0]['url'] ?? 'no-image', customFields: $this->getRelevantCustomFields($row['customFields'] ?? [])); } - - $results = array_values(array_filter( - $results, - static fn(ShopProductResult $product): bool => $product->name !== '' - )); - + $results = array_values(array_filter($results, static fn(ShopProductResult $product): bool => $product->name !== '')); return $this->deduplicateProducts($results); } - /** - * @param ShopProductResult[] $products - * @return ShopProductResult[] - */ + /** * @param ShopProductResult[] $products * @return ShopProductResult[] */ private function rerankProducts(array $products, CommerceSearchQuery $query): array { if (count($products) <= 1) { return $products; } - $decorated = []; - foreach ($products as $index => $product) { - $decorated[] = [ - 'index' => $index, - 'score' => $this->scoreProduct($product, $query), - 'product' => $product, - ]; + $decorated[] = ['index' => $index, 'score' => $this->scoreProduct($product, $query), 'product' => $product,]; } - usort($decorated, static function (array $a, array $b): int { if ($a['score'] === $b['score']) { return $a['index'] <=> $b['index']; } - return $b['score'] <=> $a['score']; }); - - return array_values(array_map( - static fn(array $entry): ShopProductResult => $entry['product'], - $decorated - )); + return array_values(array_map(static fn(array $entry): ShopProductResult => $entry['product'], $decorated)); } private function scoreProduct(ShopProductResult $product, CommerceSearchQuery $query): int { $score = 0; - - $normalizedPrompt = $this->normalizeForMatching($query->normalizedPrompt !== '' - ? $query->normalizedPrompt - : $query->originalPrompt); - + $normalizedPrompt = $this->normalizeForMatching($query->normalizedPrompt !== '' ? $query->normalizedPrompt : $query->originalPrompt); $normalizedSearchText = $this->normalizeForMatching($query->searchText); - $normalizedBrand = $this->normalizeForMatching((string) ($query->brand ?? '')); - $normalizedSizes = array_values(array_filter(array_map( - fn(mixed $size): string => $this->normalizeForMatching((string) $size), - $query->sizes - ))); - - $normalizedQuery = trim(implode(' ', array_filter([ - $normalizedPrompt, - $normalizedSearchText, - $normalizedBrand, - implode(' ', $normalizedSizes), - ]))); - + $normalizedBrand = $this->normalizeForMatching((string)($query->brand ?? '')); + $normalizedSizes = array_values(array_filter(array_map(fn(mixed $size): string => $this->normalizeForMatching((string)$size), $query->sizes))); + $normalizedQuery = trim(implode(' ', array_filter([$normalizedPrompt, $normalizedSearchText, $normalizedBrand, implode(' ', $normalizedSizes),]))); $queryTokens = $this->tokenize($normalizedQuery); $queryNumberTokens = $this->extractNumberTokens($queryTokens); - $normalizedProductName = $this->normalizeForMatching($product->name); - $normalizedProductNumber = $this->normalizeForMatching((string) ($product->productNumber ?? '')); - $normalizedManufacturer = $this->normalizeForMatching((string) ($product->manufacturer ?? '')); + $normalizedProductNumber = $this->normalizeForMatching((string)($product->productNumber ?? '')); + $normalizedManufacturer = $this->normalizeForMatching((string)($product->manufacturer ?? '')); $normalizedProductCorpus = $this->buildNormalizedProductCorpus($product); - $productNameTokens = $this->tokenize($normalizedProductName); $productNumberTokens = $this->tokenize($normalizedProductNumber); $productCorpusTokens = $this->tokenize($normalizedProductCorpus); - $productNameNumberTokens = $this->extractNumberTokens($productNameTokens); $productNumberNumberTokens = $this->extractNumberTokens($productNumberTokens); $productCorpusNumberTokens = $this->extractNumberTokens($productCorpusTokens); - if ($normalizedProductNumber !== '' && $this->containsWholePhrase($normalizedQuery, $normalizedProductNumber)) { $score += 140; } - if ($normalizedProductName !== '' && $this->containsWholePhrase($normalizedQuery, $normalizedProductName)) { $score += 80; } - if ($normalizedBrand !== '') { if ($normalizedManufacturer !== '' && $normalizedManufacturer === $normalizedBrand) { $score += 40; @@ -300,72 +125,118 @@ final readonly class ShopSearchService $score += 20; } } - $score += $this->countOverlap($queryTokens, $productNameTokens) * 6; $score += $this->countOverlap($queryTokens, $productNumberTokens) * 10; $score += $this->countOverlap($queryTokens, $productCorpusTokens) * 2; - $score += $this->countOverlap($queryNumberTokens, $productNameNumberTokens) * 18; $score += $this->countOverlap($queryNumberTokens, $productNumberNumberTokens) * 28; $score += $this->countOverlap($queryNumberTokens, $productCorpusNumberTokens) * 8; - foreach ($normalizedSizes as $normalizedSize) { if ($normalizedSize === '') { continue; } - - if ($this->containsWholePhrase($normalizedProductName, $normalizedSize) - || $this->containsWholePhrase($normalizedProductNumber, $normalizedSize) - || $this->containsWholePhrase($normalizedProductCorpus, $normalizedSize)) { + if ($this->containsWholePhrase($normalizedProductName, $normalizedSize) || $this->containsWholePhrase($normalizedProductNumber, $normalizedSize) || $this->containsWholePhrase($normalizedProductCorpus, $normalizedSize)) { $score += 12; } } - + $score += $this->scoreProductTypeMatch($product, $normalizedQuery); if ($product->available === true) { $score += 1; } - return $score; } + private function scoreProductTypeMatch(ShopProductResult $product, string $normalizedQuery): int + { + $score = 0; + $isDeviceQuery = $this->isDeviceQuery($normalizedQuery); + $isAccessoryQuery = $this->isAccessoryQuery($normalizedQuery); + if (!$isDeviceQuery && !$isAccessoryQuery) { + return 0; + } + $isAccessoryLikeProduct = $this->isAccessoryLikeProduct($product); + $isDeviceLikeProduct = $this->isDeviceLikeProduct($product); + if ($isDeviceQuery && !$isAccessoryQuery) { + if ($isDeviceLikeProduct) { + $score += 60; + } + if ($isAccessoryLikeProduct) { + $score -= 120; + } + } + if ($isAccessoryQuery) { + if ($isAccessoryLikeProduct) { + $score += 30; + } + if ($isDeviceLikeProduct) { + $score += 10; + } + } + return $score; + } + + private function isDeviceQuery(string $normalizedQuery): bool + { + foreach (ShopServiceConfig::DEVICE_QUERY_KEYWORDS as $keyword) { + if (str_contains($normalizedQuery, $this->normalizeForMatching($keyword))) { + return true; + } + } + return false; + } + + private function isAccessoryQuery(string $normalizedQuery): bool + { + foreach (ShopServiceConfig::ACCESSORY_QUERY_KEYWORDS as $keyword) { + if (str_contains($normalizedQuery, $this->normalizeForMatching($keyword))) { + return true; + } + } + return false; + } + + private function isAccessoryLikeProduct(ShopProductResult $product): bool + { + $corpus = $this->buildNormalizedProductCorpus($product); + foreach (ShopServiceConfig::ACCESSORY_PRODUCT_KEYWORDS as $keyword) { + if (str_contains($corpus, $this->normalizeForMatching($keyword))) { + return true; + } + } + return false; + } + + private function isDeviceLikeProduct(ShopProductResult $product): bool + { + $corpus = $this->buildNormalizedProductCorpus($product); + foreach (ShopServiceConfig::DEVICE_PRODUCT_KEYWORDS as $keyword) { + if (str_contains($corpus, $this->normalizeForMatching($keyword))) { + return true; + } + } + return false; + } + private function buildNormalizedProductCorpus(ShopProductResult $product): string { - return $this->normalizeForMatching(implode(' ', array_filter([ - $product->name, - $product->productNumber, - $product->manufacturer, - implode(' ', $product->highlights), - $product->description, - $product->customFields, - ]))); + return $this->normalizeForMatching(implode(' ', array_filter([$product->name, $product->productNumber, $product->manufacturer, implode(' ', $product->highlights), $product->description, $product->customFields, $product->url,]))); } - /** - * @param string[] $left - * @param string[] $right - */ + /** * @param string[] $left * @param string[] $right */ private function countOverlap(array $left, array $right): int { if ($left === [] || $right === []) { return 0; } - $leftSet = array_fill_keys($left, true); $rightSet = array_fill_keys($right, true); - return count(array_intersect_key($leftSet, $rightSet)); } - /** - * @param string[] $tokens - * @return string[] - */ + /** * @param string[] $tokens * @return string[] */ private function extractNumberTokens(array $tokens): array { - return array_values(array_filter( - $tokens, - static fn(string $token): bool => preg_match('/\d/u', $token) === 1 - )); + return array_values(array_filter($tokens, static fn(string $token): bool => preg_match('/\d/u', $token) === 1)); } private function normalizeForMatching(string $value): string @@ -373,19 +244,15 @@ final readonly class ShopSearchService $value = mb_strtolower(trim($value)); $value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value; $value = preg_replace('/\s+/u', ' ', $value) ?? $value; - return trim($value); } - /** - * @return string[] - */ + /** * @return string[] */ private function tokenize(string $value): array { if ($value === '') { return []; } - return preg_split('/[^\p{L}\p{N}]+/u', $value, -1, PREG_SPLIT_NO_EMPTY) ?: []; } @@ -394,7 +261,6 @@ final readonly class ShopSearchService if ($normalizedText === '' || $normalizedPhrase === '') { return false; } - return str_contains(' ' . $normalizedText . ' ', ' ' . $normalizedPhrase . ' '); } @@ -403,88 +269,66 @@ final readonly class ShopSearchService $result = ($customField['migration_Backup_product_attr1'] ?? '') . ': ' . ($customField['migration_Backup_product_attr2'] ?? ''); $result .= ' | Einsatzgebiete: ' . ($customField['migration_Backup_product_attr4'] ?? ''); $result .= ' | Sprachen: ' . ($customField['migration_Backup_product_attr5'] ?? ''); - return trim($result); } private function cleanUpDescription(array $description): string { if (isset($description['translated']['description'])) { - $newDesc = strip_tags((string) ($description['translated']['description'])); + $newDesc = strip_tags((string)($description['translated']['description'])); $newDesc = html_entity_decode($newDesc); $newDesc = preg_replace('/^[ \t]*\R/m', '', $newDesc); $newDesc = preg_replace('/[ \t]{2,}/', ' ', $newDesc); - $result = trim((string) $newDesc); - + $result = trim((string)$newDesc); return mb_substr($result, 0, 1500); } - return ''; } private function extractManufacturer(array $row): ?string { $manufacturer = $row['manufacturer'] ?? null; - if (is_array($manufacturer) && isset($manufacturer['name']) && is_string($manufacturer['name'])) { $name = trim($manufacturer['name']); - return $name !== '' ? $name : null; } - return null; } private function extractPrice(array $row): ?string { $calculatedPrice = $row['calculatedPrice'] ?? null; - if (!is_array($calculatedPrice)) { return null; } - - $candidates = [ - $calculatedPrice['unitPrice'] ?? null, - $calculatedPrice['totalPrice'] ?? null, - $calculatedPrice['referencePrice'] ?? null, - $calculatedPrice['listPrice'] ?? null, - $calculatedPrice['regulationPrice'] ?? null, - ]; - + $candidates = [$calculatedPrice['unitPrice'] ?? null, $calculatedPrice['totalPrice'] ?? null, $calculatedPrice['referencePrice'] ?? null, $calculatedPrice['listPrice'] ?? null, $calculatedPrice['regulationPrice'] ?? null,]; foreach ($candidates as $candidate) { if (!is_numeric($candidate)) { continue; } - - $value = (float) $candidate; - + $value = (float)$candidate; if ($value > 0.0) { return number_format($value, 2, ',', '.') . ' €'; } } - return null; } private function extractUrl(array $row): ?string { $seoUrls = $row['seoUrls'] ?? null; - if (!is_array($seoUrls) || $seoUrls === []) { return null; } - foreach ($seoUrls as $seoUrl) { if (!is_array($seoUrl)) { continue; } - $path = $seoUrl['seoPathInfo'] ?? null; if (is_string($path) && trim($path) !== '') { return '/' . ltrim($path, '/'); } } - return null; } @@ -493,53 +337,35 @@ final readonly class ShopSearchService if ($relativeUrl === null || trim($relativeUrl) === '') { return null; } - return rtrim($this->baseUrl, '/') . '/' . ltrim($relativeUrl, '/'); } - /** - * @return string[] - */ + /** * @return string[] */ private function extractHighlights(array $row): array { $highlights = []; - if (isset($row['available'])) { - $highlights[] = (bool) $row['available'] ? 'Verfügbar' : 'Nicht verfügbar'; + $highlights[] = (bool)$row['available'] ? 'Verfügbar' : 'Nicht verfügbar'; } - if (isset($row['productNumber']) && is_string($row['productNumber']) && trim($row['productNumber']) !== '') { $highlights[] = 'Produktnummer: ' . trim($row['productNumber']); } - return array_values(array_unique($highlights)); } - /** - * @param ShopProductResult[] $products - * @return ShopProductResult[] - */ + /** * @param ShopProductResult[] $products * @return ShopProductResult[] */ private function deduplicateProducts(array $products): array { $unique = []; $seen = []; - foreach ($products as $product) { - $key = mb_strtolower(trim(implode('|', [ - $product->id, - $product->productNumber ?? '', - $product->name, - $product->url ?? '', - ]))); - + $key = mb_strtolower(trim(implode('|', [$product->id, $product->productNumber ?? '', $product->name, $product->url ?? '',]))); if (isset($seen[$key])) { continue; } - $seen[$key] = true; $unique[] = $product; } - return $unique; } } \ No newline at end of file diff --git a/src/Config/CommerceIntentConfig.php b/src/Config/CommerceIntentConfig.php index 19f7cf8..6351557 100644 --- a/src/Config/CommerceIntentConfig.php +++ b/src/Config/CommerceIntentConfig.php @@ -1,8 +1,10 @@ isSupportOrDiagnosticQuery($p) && !$this->hasExplicitCommerceIntent($p)) { + return [ + 'intent' => self::NONE, + 'score' => 0, + 'signals' => ['support_or_diagnostic'], + ]; + } + $score = 0; $signals = []; $strongSignals = $this->config->getStrongSignalsList(); foreach ($strongSignals as $signal) { - if (str_contains($p, strtolower($signal))) { + if (str_contains($p, mb_strtolower($signal))) { $score += 3; $signals[] = $signal; } } - if (preg_match('#\d{3,10}#', $p)) { + // Treat long numeric identifiers as stronger product-number-like signals. + // This avoids over-triggering commerce purely because a model name contains + // a short number such as "808" in support questions. + if (preg_match('/\b\d{4,10}\b/u', $p) === 1) { $score += 2; $signals[] = 'sku'; } @@ -78,7 +89,7 @@ final class CommerceIntentLite $advisorySignals = $this->config->getAdvisorySignals(); foreach ($advisorySignals as $signal) { - if (str_contains($p, $signal)) { + if (str_contains($p, mb_strtolower($signal))) { $score += 1; $signals[] = 'advisory:' . $signal; } @@ -109,4 +120,65 @@ final class CommerceIntentLite ]; } + private function isSupportOrDiagnosticQuery(string $prompt): bool + { + $patterns = [ + '/\bfehler\b/u', + '/\bfehlercode\b/u', + '/\berror\b/u', + '/\bstörung\b/u', + '/\bstoerung\b/u', + '/\balarm\b/u', + '/\bstörungsmeldung\b/u', + '/\bstoerungsmeldung\b/u', + '/\bmeldung\b/u', + '/\bwarnung\b/u', + '/\bwarncode\b/u', + '/\bcode\b/u', + '/\bwas bedeutet\b/u', + '/\bwarum\b/u', + '/\bblinkt\b/u', + '/\bzeigt\b/u', + '/\bzeigt an\b/u', + '/\bursache\b/u', + '/\bdiagnose\b/u', + '/\bservicefall\b/u', + '/\bproblem\b/u', + '/\bstörung beheben\b/u', + '/\bstoerung beheben\b/u', + '/\be\d{1,3}\b/u', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $prompt) === 1) { + return true; + } + } + + return false; + } + + private function hasExplicitCommerceIntent(string $prompt): bool + { + $patterns = [ + '/\bshop\b/u', + '/\bpreis\b/u', + '/\bkosten\b/u', + '/\bkostet\b/u', + '/\bkaufen\b/u', + '/\bbestellen\b/u', + '/\bprodukt\b/u', + '/\bartikel\b/u', + '/\bsku\b/u', + '/\bonline\b/u', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $prompt) === 1) { + return true; + } + } + + return false; + } } \ No newline at end of file