optimize weigths by rag and shop
This commit is contained in:
@@ -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" .
|
"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" .
|
"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" .
|
"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)) {
|
if ($totalCount > count($limitedShopResults)) {
|
||||||
$header .= "\n" .
|
$header .= "\n" .
|
||||||
@@ -268,16 +270,27 @@ final readonly class PromptBuilder
|
|||||||
"- Use short, clean paragraphs or short labeled sections.",
|
"- Use short, clean paragraphs or short labeled sections.",
|
||||||
"- Do not use persuasive or promotional wording.",
|
"- Do not use persuasive or promotional wording.",
|
||||||
"- Do not repeat the same fact in slightly different 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) {
|
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[] = "- 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[] = "- 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) {
|
if ($isTechnicalProductQuestion) {
|
||||||
$rules[] = "- Write like technical documentation: precise, neutral, and source-close.";
|
$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[] = "- 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)) {
|
if ($this->asksForAccessoryOrBundle($prompt)) {
|
||||||
@@ -407,6 +420,8 @@ final readonly class PromptBuilder
|
|||||||
"- Clearly separate explicit facts from inferences.",
|
"- Clearly separate explicit facts from inferences.",
|
||||||
"- If a conclusion goes beyond the source wording, label it exactly as 'Inference:'.",
|
"- 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.",
|
"- 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) {
|
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 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.",
|
||||||
|
"- 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 {
|
} else {
|
||||||
$rules[] = "- Use retrieved knowledge as authoritative for factual answers.";
|
$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) {
|
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 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.",
|
"- If a detail is not explicitly stated in the provided sources, say so plainly.",
|
||||||
"- Prefer short, source-close sentences over explanatory expansion.",
|
"- 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.",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<?php
|
<?php declare(strict_types=1);
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Commerce;
|
namespace App\Commerce;
|
||||||
|
|
||||||
use App\Commerce\Dto\CommerceSearchQuery;
|
use App\Commerce\Dto\CommerceSearchQuery;
|
||||||
use App\Commerce\Dto\ShopProductResult;
|
use App\Commerce\Dto\ShopProductResult;
|
||||||
|
use App\Config\ShopServiceConfig;
|
||||||
use App\Shopware\ShopwareCriteriaBuilder;
|
use App\Shopware\ShopwareCriteriaBuilder;
|
||||||
use App\Shopware\StoreApiClient;
|
use App\Shopware\StoreApiClient;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -16,283 +15,109 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
|||||||
|
|
||||||
final readonly class ShopSearchService
|
final readonly class ShopSearchService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(private CommerceQueryParser $queryParser, private ShopwareCriteriaBuilder $criteriaBuilder, private StoreApiClient $storeApiClient, private LoggerInterface $logger, private bool $enabled = true, private int $maxResults = 25, private string $baseUrl)
|
||||||
private CommerceQueryParser $queryParser,
|
{
|
||||||
private ShopwareCriteriaBuilder $criteriaBuilder,
|
|
||||||
private StoreApiClient $storeApiClient,
|
|
||||||
private LoggerInterface $logger,
|
|
||||||
private bool $enabled = true,
|
|
||||||
private int $maxResults = 25,
|
|
||||||
private string $baseUrl
|
|
||||||
) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** * @return ShopProductResult[] */
|
||||||
* @return ShopProductResult[]
|
public function search(string $originalPrompt, string $commerceIntent, string $commerceHistoryContext = ''): array
|
||||||
*/
|
{
|
||||||
public function search(
|
|
||||||
string $originalPrompt,
|
|
||||||
string $commerceIntent,
|
|
||||||
string $commerceHistoryContext = ''
|
|
||||||
): array {
|
|
||||||
if (!$this->enabled) {
|
if (!$this->enabled) {
|
||||||
$this->logger->info('Shop search skipped because commerce search is disabled', [
|
$this->logger->info('Shop search skipped because commerce search is disabled', ['commerceIntent' => $commerceIntent,]);
|
||||||
'commerceIntent' => $commerceIntent,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
$primaryQuery = $this->queryParser->parse($originalPrompt, $commerceIntent, $commerceHistoryContext);
|
||||||
$primaryQuery = $this->queryParser->parse(
|
$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,]);
|
||||||
$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);
|
$rankedProducts = $this->executeSearch($primaryQuery, $commerceIntent, $originalPrompt, true);
|
||||||
|
|
||||||
if ($rankedProducts === [] && $commerceHistoryContext !== '') {
|
if ($rankedProducts === [] && $commerceHistoryContext !== '') {
|
||||||
$fallbackQuery = $this->queryParser->parse(
|
$fallbackQuery = $this->queryParser->parse($originalPrompt, $commerceIntent, '');
|
||||||
$originalPrompt,
|
$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,]);
|
||||||
$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);
|
$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 $rankedProducts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** * @return ShopProductResult[] */
|
||||||
* @return ShopProductResult[]
|
private function executeSearch(CommerceSearchQuery $query, string $commerceIntent, string $originalPrompt, bool $usesHistoryContext): array
|
||||||
*/
|
{
|
||||||
private function executeSearch(
|
|
||||||
CommerceSearchQuery $query,
|
|
||||||
string $commerceIntent,
|
|
||||||
string $originalPrompt,
|
|
||||||
bool $usesHistoryContext
|
|
||||||
): array {
|
|
||||||
$criteria = $this->criteriaBuilder->build($query, $this->maxResults);
|
$criteria = $this->criteriaBuilder->build($query, $this->maxResults);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->storeApiClient->searchProducts($criteria);
|
$response = $this->storeApiClient->searchProducts($criteria);
|
||||||
} catch (
|
} catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $e) {
|
||||||
ClientExceptionInterface
|
$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(),]);
|
||||||
| 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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$mappedProducts = $this->mapProducts($response);
|
$mappedProducts = $this->mapProducts($response);
|
||||||
$rankedProducts = $this->rerankProducts($mappedProducts, $query);
|
$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 $rankedProducts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** * @return ShopProductResult[] */
|
||||||
* @return ShopProductResult[]
|
|
||||||
*/
|
|
||||||
private function mapProducts(array $response): array
|
private function mapProducts(array $response): array
|
||||||
{
|
{
|
||||||
$elements = $response['elements'] ?? [];
|
$elements = $response['elements'] ?? [];
|
||||||
if (!is_array($elements)) {
|
if (!is_array($elements)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$results = [];
|
$results = [];
|
||||||
|
|
||||||
foreach ($elements as $row) {
|
foreach ($elements as $row) {
|
||||||
if (!is_array($row)) {
|
if (!is_array($row)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$relativeUrl = $this->extractUrl($row);
|
$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);
|
return $this->deduplicateProducts($results);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** * @param ShopProductResult[] $products * @return ShopProductResult[] */
|
||||||
* @param ShopProductResult[] $products
|
|
||||||
* @return ShopProductResult[]
|
|
||||||
*/
|
|
||||||
private function rerankProducts(array $products, CommerceSearchQuery $query): array
|
private function rerankProducts(array $products, CommerceSearchQuery $query): array
|
||||||
{
|
{
|
||||||
if (count($products) <= 1) {
|
if (count($products) <= 1) {
|
||||||
return $products;
|
return $products;
|
||||||
}
|
}
|
||||||
|
|
||||||
$decorated = [];
|
$decorated = [];
|
||||||
|
|
||||||
foreach ($products as $index => $product) {
|
foreach ($products as $index => $product) {
|
||||||
$decorated[] = [
|
$decorated[] = ['index' => $index, 'score' => $this->scoreProduct($product, $query), 'product' => $product,];
|
||||||
'index' => $index,
|
|
||||||
'score' => $this->scoreProduct($product, $query),
|
|
||||||
'product' => $product,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
usort($decorated, static function (array $a, array $b): int {
|
usort($decorated, static function (array $a, array $b): int {
|
||||||
if ($a['score'] === $b['score']) {
|
if ($a['score'] === $b['score']) {
|
||||||
return $a['index'] <=> $b['index'];
|
return $a['index'] <=> $b['index'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $b['score'] <=> $a['score'];
|
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
|
private function scoreProduct(ShopProductResult $product, CommerceSearchQuery $query): int
|
||||||
{
|
{
|
||||||
$score = 0;
|
$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);
|
$normalizedSearchText = $this->normalizeForMatching($query->searchText);
|
||||||
$normalizedBrand = $this->normalizeForMatching((string) ($query->brand ?? ''));
|
$normalizedBrand = $this->normalizeForMatching((string)($query->brand ?? ''));
|
||||||
$normalizedSizes = array_values(array_filter(array_map(
|
$normalizedSizes = array_values(array_filter(array_map(fn(mixed $size): string => $this->normalizeForMatching((string)$size), $query->sizes)));
|
||||||
fn(mixed $size): string => $this->normalizeForMatching((string) $size),
|
$normalizedQuery = trim(implode(' ', array_filter([$normalizedPrompt, $normalizedSearchText, $normalizedBrand, implode(' ', $normalizedSizes),])));
|
||||||
$query->sizes
|
|
||||||
)));
|
|
||||||
|
|
||||||
$normalizedQuery = trim(implode(' ', array_filter([
|
|
||||||
$normalizedPrompt,
|
|
||||||
$normalizedSearchText,
|
|
||||||
$normalizedBrand,
|
|
||||||
implode(' ', $normalizedSizes),
|
|
||||||
])));
|
|
||||||
|
|
||||||
$queryTokens = $this->tokenize($normalizedQuery);
|
$queryTokens = $this->tokenize($normalizedQuery);
|
||||||
$queryNumberTokens = $this->extractNumberTokens($queryTokens);
|
$queryNumberTokens = $this->extractNumberTokens($queryTokens);
|
||||||
|
|
||||||
$normalizedProductName = $this->normalizeForMatching($product->name);
|
$normalizedProductName = $this->normalizeForMatching($product->name);
|
||||||
$normalizedProductNumber = $this->normalizeForMatching((string) ($product->productNumber ?? ''));
|
$normalizedProductNumber = $this->normalizeForMatching((string)($product->productNumber ?? ''));
|
||||||
$normalizedManufacturer = $this->normalizeForMatching((string) ($product->manufacturer ?? ''));
|
$normalizedManufacturer = $this->normalizeForMatching((string)($product->manufacturer ?? ''));
|
||||||
$normalizedProductCorpus = $this->buildNormalizedProductCorpus($product);
|
$normalizedProductCorpus = $this->buildNormalizedProductCorpus($product);
|
||||||
|
|
||||||
$productNameTokens = $this->tokenize($normalizedProductName);
|
$productNameTokens = $this->tokenize($normalizedProductName);
|
||||||
$productNumberTokens = $this->tokenize($normalizedProductNumber);
|
$productNumberTokens = $this->tokenize($normalizedProductNumber);
|
||||||
$productCorpusTokens = $this->tokenize($normalizedProductCorpus);
|
$productCorpusTokens = $this->tokenize($normalizedProductCorpus);
|
||||||
|
|
||||||
$productNameNumberTokens = $this->extractNumberTokens($productNameTokens);
|
$productNameNumberTokens = $this->extractNumberTokens($productNameTokens);
|
||||||
$productNumberNumberTokens = $this->extractNumberTokens($productNumberTokens);
|
$productNumberNumberTokens = $this->extractNumberTokens($productNumberTokens);
|
||||||
$productCorpusNumberTokens = $this->extractNumberTokens($productCorpusTokens);
|
$productCorpusNumberTokens = $this->extractNumberTokens($productCorpusTokens);
|
||||||
|
|
||||||
if ($normalizedProductNumber !== '' && $this->containsWholePhrase($normalizedQuery, $normalizedProductNumber)) {
|
if ($normalizedProductNumber !== '' && $this->containsWholePhrase($normalizedQuery, $normalizedProductNumber)) {
|
||||||
$score += 140;
|
$score += 140;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($normalizedProductName !== '' && $this->containsWholePhrase($normalizedQuery, $normalizedProductName)) {
|
if ($normalizedProductName !== '' && $this->containsWholePhrase($normalizedQuery, $normalizedProductName)) {
|
||||||
$score += 80;
|
$score += 80;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($normalizedBrand !== '') {
|
if ($normalizedBrand !== '') {
|
||||||
if ($normalizedManufacturer !== '' && $normalizedManufacturer === $normalizedBrand) {
|
if ($normalizedManufacturer !== '' && $normalizedManufacturer === $normalizedBrand) {
|
||||||
$score += 40;
|
$score += 40;
|
||||||
@@ -300,72 +125,118 @@ final readonly class ShopSearchService
|
|||||||
$score += 20;
|
$score += 20;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$score += $this->countOverlap($queryTokens, $productNameTokens) * 6;
|
$score += $this->countOverlap($queryTokens, $productNameTokens) * 6;
|
||||||
$score += $this->countOverlap($queryTokens, $productNumberTokens) * 10;
|
$score += $this->countOverlap($queryTokens, $productNumberTokens) * 10;
|
||||||
$score += $this->countOverlap($queryTokens, $productCorpusTokens) * 2;
|
$score += $this->countOverlap($queryTokens, $productCorpusTokens) * 2;
|
||||||
|
|
||||||
$score += $this->countOverlap($queryNumberTokens, $productNameNumberTokens) * 18;
|
$score += $this->countOverlap($queryNumberTokens, $productNameNumberTokens) * 18;
|
||||||
$score += $this->countOverlap($queryNumberTokens, $productNumberNumberTokens) * 28;
|
$score += $this->countOverlap($queryNumberTokens, $productNumberNumberTokens) * 28;
|
||||||
$score += $this->countOverlap($queryNumberTokens, $productCorpusNumberTokens) * 8;
|
$score += $this->countOverlap($queryNumberTokens, $productCorpusNumberTokens) * 8;
|
||||||
|
|
||||||
foreach ($normalizedSizes as $normalizedSize) {
|
foreach ($normalizedSizes as $normalizedSize) {
|
||||||
if ($normalizedSize === '') {
|
if ($normalizedSize === '') {
|
||||||
continue;
|
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 += 12;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$score += $this->scoreProductTypeMatch($product, $normalizedQuery);
|
||||||
if ($product->available === true) {
|
if ($product->available === true) {
|
||||||
$score += 1;
|
$score += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $score;
|
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
|
private function buildNormalizedProductCorpus(ShopProductResult $product): string
|
||||||
{
|
{
|
||||||
return $this->normalizeForMatching(implode(' ', array_filter([
|
return $this->normalizeForMatching(implode(' ', array_filter([$product->name, $product->productNumber, $product->manufacturer, implode(' ', $product->highlights), $product->description, $product->customFields, $product->url,])));
|
||||||
$product->name,
|
|
||||||
$product->productNumber,
|
|
||||||
$product->manufacturer,
|
|
||||||
implode(' ', $product->highlights),
|
|
||||||
$product->description,
|
|
||||||
$product->customFields,
|
|
||||||
])));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** * @param string[] $left * @param string[] $right */
|
||||||
* @param string[] $left
|
|
||||||
* @param string[] $right
|
|
||||||
*/
|
|
||||||
private function countOverlap(array $left, array $right): int
|
private function countOverlap(array $left, array $right): int
|
||||||
{
|
{
|
||||||
if ($left === [] || $right === []) {
|
if ($left === [] || $right === []) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$leftSet = array_fill_keys($left, true);
|
$leftSet = array_fill_keys($left, true);
|
||||||
$rightSet = array_fill_keys($right, true);
|
$rightSet = array_fill_keys($right, true);
|
||||||
|
|
||||||
return count(array_intersect_key($leftSet, $rightSet));
|
return count(array_intersect_key($leftSet, $rightSet));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** * @param string[] $tokens * @return string[] */
|
||||||
* @param string[] $tokens
|
|
||||||
* @return string[]
|
|
||||||
*/
|
|
||||||
private function extractNumberTokens(array $tokens): array
|
private function extractNumberTokens(array $tokens): array
|
||||||
{
|
{
|
||||||
return array_values(array_filter(
|
return array_values(array_filter($tokens, static fn(string $token): bool => preg_match('/\d/u', $token) === 1));
|
||||||
$tokens,
|
|
||||||
static fn(string $token): bool => preg_match('/\d/u', $token) === 1
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function normalizeForMatching(string $value): string
|
private function normalizeForMatching(string $value): string
|
||||||
@@ -373,19 +244,15 @@ final readonly class ShopSearchService
|
|||||||
$value = mb_strtolower(trim($value));
|
$value = mb_strtolower(trim($value));
|
||||||
$value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
|
$value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
|
||||||
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||||
|
|
||||||
return trim($value);
|
return trim($value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** * @return string[] */
|
||||||
* @return string[]
|
|
||||||
*/
|
|
||||||
private function tokenize(string $value): array
|
private function tokenize(string $value): array
|
||||||
{
|
{
|
||||||
if ($value === '') {
|
if ($value === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return preg_split('/[^\p{L}\p{N}]+/u', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
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 === '') {
|
if ($normalizedText === '' || $normalizedPhrase === '') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return str_contains(' ' . $normalizedText . ' ', ' ' . $normalizedPhrase . ' ');
|
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 = ($customField['migration_Backup_product_attr1'] ?? '') . ': ' . ($customField['migration_Backup_product_attr2'] ?? '');
|
||||||
$result .= ' | Einsatzgebiete: ' . ($customField['migration_Backup_product_attr4'] ?? '');
|
$result .= ' | Einsatzgebiete: ' . ($customField['migration_Backup_product_attr4'] ?? '');
|
||||||
$result .= ' | Sprachen: ' . ($customField['migration_Backup_product_attr5'] ?? '');
|
$result .= ' | Sprachen: ' . ($customField['migration_Backup_product_attr5'] ?? '');
|
||||||
|
|
||||||
return trim($result);
|
return trim($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function cleanUpDescription(array $description): string
|
private function cleanUpDescription(array $description): string
|
||||||
{
|
{
|
||||||
if (isset($description['translated']['description'])) {
|
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 = html_entity_decode($newDesc);
|
||||||
$newDesc = preg_replace('/^[ \t]*\R/m', '', $newDesc);
|
$newDesc = preg_replace('/^[ \t]*\R/m', '', $newDesc);
|
||||||
$newDesc = preg_replace('/[ \t]{2,}/', ' ', $newDesc);
|
$newDesc = preg_replace('/[ \t]{2,}/', ' ', $newDesc);
|
||||||
$result = trim((string) $newDesc);
|
$result = trim((string)$newDesc);
|
||||||
|
|
||||||
return mb_substr($result, 0, 1500);
|
return mb_substr($result, 0, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function extractManufacturer(array $row): ?string
|
private function extractManufacturer(array $row): ?string
|
||||||
{
|
{
|
||||||
$manufacturer = $row['manufacturer'] ?? null;
|
$manufacturer = $row['manufacturer'] ?? null;
|
||||||
|
|
||||||
if (is_array($manufacturer) && isset($manufacturer['name']) && is_string($manufacturer['name'])) {
|
if (is_array($manufacturer) && isset($manufacturer['name']) && is_string($manufacturer['name'])) {
|
||||||
$name = trim($manufacturer['name']);
|
$name = trim($manufacturer['name']);
|
||||||
|
|
||||||
return $name !== '' ? $name : null;
|
return $name !== '' ? $name : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function extractPrice(array $row): ?string
|
private function extractPrice(array $row): ?string
|
||||||
{
|
{
|
||||||
$calculatedPrice = $row['calculatedPrice'] ?? null;
|
$calculatedPrice = $row['calculatedPrice'] ?? null;
|
||||||
|
|
||||||
if (!is_array($calculatedPrice)) {
|
if (!is_array($calculatedPrice)) {
|
||||||
return null;
|
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) {
|
foreach ($candidates as $candidate) {
|
||||||
if (!is_numeric($candidate)) {
|
if (!is_numeric($candidate)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
$value = (float)$candidate;
|
||||||
$value = (float) $candidate;
|
|
||||||
|
|
||||||
if ($value > 0.0) {
|
if ($value > 0.0) {
|
||||||
return number_format($value, 2, ',', '.') . ' €';
|
return number_format($value, 2, ',', '.') . ' €';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function extractUrl(array $row): ?string
|
private function extractUrl(array $row): ?string
|
||||||
{
|
{
|
||||||
$seoUrls = $row['seoUrls'] ?? null;
|
$seoUrls = $row['seoUrls'] ?? null;
|
||||||
|
|
||||||
if (!is_array($seoUrls) || $seoUrls === []) {
|
if (!is_array($seoUrls) || $seoUrls === []) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($seoUrls as $seoUrl) {
|
foreach ($seoUrls as $seoUrl) {
|
||||||
if (!is_array($seoUrl)) {
|
if (!is_array($seoUrl)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = $seoUrl['seoPathInfo'] ?? null;
|
$path = $seoUrl['seoPathInfo'] ?? null;
|
||||||
if (is_string($path) && trim($path) !== '') {
|
if (is_string($path) && trim($path) !== '') {
|
||||||
return '/' . ltrim($path, '/');
|
return '/' . ltrim($path, '/');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,53 +337,35 @@ final readonly class ShopSearchService
|
|||||||
if ($relativeUrl === null || trim($relativeUrl) === '') {
|
if ($relativeUrl === null || trim($relativeUrl) === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return rtrim($this->baseUrl, '/') . '/' . ltrim($relativeUrl, '/');
|
return rtrim($this->baseUrl, '/') . '/' . ltrim($relativeUrl, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** * @return string[] */
|
||||||
* @return string[]
|
|
||||||
*/
|
|
||||||
private function extractHighlights(array $row): array
|
private function extractHighlights(array $row): array
|
||||||
{
|
{
|
||||||
$highlights = [];
|
$highlights = [];
|
||||||
|
|
||||||
if (isset($row['available'])) {
|
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']) !== '') {
|
if (isset($row['productNumber']) && is_string($row['productNumber']) && trim($row['productNumber']) !== '') {
|
||||||
$highlights[] = 'Produktnummer: ' . trim($row['productNumber']);
|
$highlights[] = 'Produktnummer: ' . trim($row['productNumber']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_unique($highlights));
|
return array_values(array_unique($highlights));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** * @param ShopProductResult[] $products * @return ShopProductResult[] */
|
||||||
* @param ShopProductResult[] $products
|
|
||||||
* @return ShopProductResult[]
|
|
||||||
*/
|
|
||||||
private function deduplicateProducts(array $products): array
|
private function deduplicateProducts(array $products): array
|
||||||
{
|
{
|
||||||
$unique = [];
|
$unique = [];
|
||||||
$seen = [];
|
$seen = [];
|
||||||
|
|
||||||
foreach ($products as $product) {
|
foreach ($products as $product) {
|
||||||
$key = mb_strtolower(trim(implode('|', [
|
$key = mb_strtolower(trim(implode('|', [$product->id, $product->productNumber ?? '', $product->name, $product->url ?? '',])));
|
||||||
$product->id,
|
|
||||||
$product->productNumber ?? '',
|
|
||||||
$product->name,
|
|
||||||
$product->url ?? '',
|
|
||||||
])));
|
|
||||||
|
|
||||||
if (isset($seen[$key])) {
|
if (isset($seen[$key])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$seen[$key] = true;
|
$seen[$key] = true;
|
||||||
$unique[] = $product;
|
$unique[] = $product;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $unique;
|
return $unique;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Config;
|
namespace App\Config;
|
||||||
|
|
||||||
class CommerceIntentConfig
|
final class CommerceIntentConfig
|
||||||
{
|
{
|
||||||
public function getStrongSignalsList(): array
|
public function getStrongSignalsList(): array
|
||||||
{
|
{
|
||||||
@@ -13,10 +15,24 @@ class CommerceIntentConfig
|
|||||||
'kunde',
|
'kunde',
|
||||||
'online',
|
'online',
|
||||||
'produkt',
|
'produkt',
|
||||||
|
'artikel',
|
||||||
'sku',
|
'sku',
|
||||||
'Artikel',
|
|
||||||
'kaufen',
|
'kaufen',
|
||||||
'kostet',
|
'kostet',
|
||||||
|
|
||||||
|
// Search / product discovery signals
|
||||||
|
'suche',
|
||||||
|
'such',
|
||||||
|
'finde',
|
||||||
|
'finden',
|
||||||
|
|
||||||
|
// Product-type signals for technical/commercial requests
|
||||||
|
'analysegerät',
|
||||||
|
'analysegeraet',
|
||||||
|
'messgerät',
|
||||||
|
'messgeraet',
|
||||||
|
'analysator',
|
||||||
|
'analyzer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class PromptBuilderConfig{
|
|||||||
* The shop search may return many candidates, but the LLM should only see
|
* The shop search may return many candidates, but the LLM should only see
|
||||||
* the most relevant top subset after local reranking.
|
* the most relevant top subset after local reranking.
|
||||||
*/
|
*/
|
||||||
public const MAX_SHOP_RESULTS_IN_PROMPT = 10;
|
public const MAX_SHOP_RESULTS_IN_PROMPT = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Technical product prompts should be answered like documentation,
|
* Technical product prompts should be answered like documentation,
|
||||||
@@ -75,6 +75,8 @@ class PromptBuilderConfig{
|
|||||||
'wasserhärte',
|
'wasserhärte',
|
||||||
'hardness',
|
'hardness',
|
||||||
'testomat',
|
'testomat',
|
||||||
|
'chlor',
|
||||||
|
'chlormessung',
|
||||||
];
|
];
|
||||||
|
|
||||||
public const ACCESSORY_REQUEST_KEYWORDS = [
|
public const ACCESSORY_REQUEST_KEYWORDS = [
|
||||||
@@ -92,5 +94,4 @@ class PromptBuilderConfig{
|
|||||||
'ergänzung',
|
'ergänzung',
|
||||||
'ergaenzung',
|
'ergaenzung',
|
||||||
];
|
];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
78
src/Config/ShopServiceConfig.php
Normal file
78
src/Config/ShopServiceConfig.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Config;
|
||||||
|
|
||||||
|
class ShopServiceConfig
|
||||||
|
{
|
||||||
|
public const DEVICE_QUERY_KEYWORDS = [
|
||||||
|
'analysegerät',
|
||||||
|
'analysegeraet',
|
||||||
|
'messgerät',
|
||||||
|
'messgeraet',
|
||||||
|
'analysator',
|
||||||
|
'analyzer',
|
||||||
|
'gerät',
|
||||||
|
'geraet',
|
||||||
|
'monitor',
|
||||||
|
'controller',
|
||||||
|
'gerät für',
|
||||||
|
'geraet fuer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const ACCESSORY_QUERY_KEYWORDS = [
|
||||||
|
'zubehör',
|
||||||
|
'zubehor',
|
||||||
|
'reagenz',
|
||||||
|
'reagent',
|
||||||
|
'indikator',
|
||||||
|
'indicator',
|
||||||
|
'kit',
|
||||||
|
'set',
|
||||||
|
'ersatz',
|
||||||
|
'verbrauchsmaterial',
|
||||||
|
'consumable',
|
||||||
|
'dazu',
|
||||||
|
'passend',
|
||||||
|
'passende',
|
||||||
|
'passendes',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const ACCESSORY_PRODUCT_KEYWORDS = [
|
||||||
|
'reagenz',
|
||||||
|
'reagent',
|
||||||
|
'indikator',
|
||||||
|
'indicator',
|
||||||
|
'kit',
|
||||||
|
'set',
|
||||||
|
'verbrauchsmaterial',
|
||||||
|
'consumable',
|
||||||
|
'zubehör',
|
||||||
|
'zubehor',
|
||||||
|
'ersatz',
|
||||||
|
'nachfüll',
|
||||||
|
'nachfuell',
|
||||||
|
'refill',
|
||||||
|
'lösung',
|
||||||
|
'loesung',
|
||||||
|
'solution',
|
||||||
|
'teststreifen',
|
||||||
|
'test strip',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const DEVICE_PRODUCT_KEYWORDS = [
|
||||||
|
'analysegerät',
|
||||||
|
'analysegeraet',
|
||||||
|
'messgerät',
|
||||||
|
'messgeraet',
|
||||||
|
'analysator',
|
||||||
|
'analyzer',
|
||||||
|
'monitor',
|
||||||
|
'controller',
|
||||||
|
'online-analysator',
|
||||||
|
'online analysator',
|
||||||
|
'online-analysegerät',
|
||||||
|
'online analysegeraet',
|
||||||
|
'online analyzer',
|
||||||
|
'online monitor',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -14,9 +14,7 @@ final class CommerceIntentLite
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CommerceIntentConfig $config
|
private readonly CommerceIntentConfig $config
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,19 +32,32 @@ final class CommerceIntentLite
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block support / diagnostic questions from entering the commerce flow
|
||||||
|
// unless the prompt also contains very explicit purchase / shop intent.
|
||||||
|
if ($this->isSupportOrDiagnosticQuery($p) && !$this->hasExplicitCommerceIntent($p)) {
|
||||||
|
return [
|
||||||
|
'intent' => self::NONE,
|
||||||
|
'score' => 0,
|
||||||
|
'signals' => ['support_or_diagnostic'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$score = 0;
|
$score = 0;
|
||||||
$signals = [];
|
$signals = [];
|
||||||
|
|
||||||
$strongSignals = $this->config->getStrongSignalsList();
|
$strongSignals = $this->config->getStrongSignalsList();
|
||||||
|
|
||||||
foreach ($strongSignals as $signal) {
|
foreach ($strongSignals as $signal) {
|
||||||
if (str_contains($p, strtolower($signal))) {
|
if (str_contains($p, mb_strtolower($signal))) {
|
||||||
$score += 3;
|
$score += 3;
|
||||||
$signals[] = $signal;
|
$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;
|
$score += 2;
|
||||||
$signals[] = 'sku';
|
$signals[] = 'sku';
|
||||||
}
|
}
|
||||||
@@ -78,7 +89,7 @@ final class CommerceIntentLite
|
|||||||
$advisorySignals = $this->config->getAdvisorySignals();
|
$advisorySignals = $this->config->getAdvisorySignals();
|
||||||
|
|
||||||
foreach ($advisorySignals as $signal) {
|
foreach ($advisorySignals as $signal) {
|
||||||
if (str_contains($p, $signal)) {
|
if (str_contains($p, mb_strtolower($signal))) {
|
||||||
$score += 1;
|
$score += 1;
|
||||||
$signals[] = 'advisory:' . $signal;
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user