add shopware store-api

This commit is contained in:
team 1
2026-04-09 12:00:34 +02:00
parent 0ef3b43b30
commit 1aee32f1d8
13 changed files with 992 additions and 10 deletions

View File

@@ -41,6 +41,12 @@ parameters:
mto.vector.timeout: 600 mto.vector.timeout: 600
mto.vector.service_url: 'http://127.0.0.1:8090' mto.vector.service_url: 'http://127.0.0.1:8090'
mto.commerce.enabled: true
mto.commerce.max_shop_results: '%env(SHOPWARE_STORE_API_MAX_RESULT)%'
mto.commerce.shop_timeout: 5
mto.commerce.store_api_base_url: '%env(SHOPWARE_STORE_API_BASE_URL)%'
mto.commerce.sales_channel_access_key: '%env(SHOPWARE_SALES_CHANNEL_ACCESS_KEY)%'
# ------------------------------------------------------------ # ------------------------------------------------------------
# Services # Services
@@ -99,10 +105,31 @@ services:
App\Knowledge\Retrieval\NdjsonHybridRetriever: ~ App\Knowledge\Retrieval\NdjsonHybridRetriever: ~
# CachedRetriever entfernt: war Interface-inkompatibel und erzeugt Drift/Chaos
App\Knowledge\Retrieval\RetrieverInterface: App\Knowledge\Retrieval\RetrieverInterface:
alias: App\Knowledge\Retrieval\NdjsonHybridRetriever alias: App\Knowledge\Retrieval\NdjsonHybridRetriever
# ------------------------------------------------------------
# Commerce / Shopware Store API
# ------------------------------------------------------------
App\Intent\CommerceIntentLite: ~
App\Commerce\CommerceQueryParser: ~
App\Shopware\ShopwareCriteriaBuilder: ~
App\Shopware\StoreApiClient:
arguments:
$baseUrl: '%mto.commerce.store_api_base_url%'
$salesChannelAccessKey: '%mto.commerce.sales_channel_access_key%'
$timeoutSeconds: '%mto.commerce.shop_timeout%'
App\Commerce\ShopSearchService:
arguments:
$enabled: '%mto.commerce.enabled%'
$maxResults: '%mto.commerce.max_shop_results%'
$baseUrl: '%mto.commerce.store_api_base_url%'
# ------------------------------------------------------------ # ------------------------------------------------------------
# Index Configuration Provider # Index Configuration Provider
# ------------------------------------------------------------ # ------------------------------------------------------------

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Agent; namespace App\Agent;
use App\Commerce\ShopSearchService;
use App\Context\ContextService; use App\Context\ContextService;
use App\Context\UrlAnalyzer; use App\Context\UrlAnalyzer;
use App\Infrastructure\OllamaClient; use App\Infrastructure\OllamaClient;
use App\Intent\CommerceIntentLite;
use App\Knowledge\Retrieval\RetrieverInterface; use App\Knowledge\Retrieval\RetrieverInterface;
use Generator; use Generator;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -21,6 +23,8 @@ final readonly class AgentRunner
private ContextService $contextService, private ContextService $contextService,
private UrlAnalyzer $urlAnalyzer, private UrlAnalyzer $urlAnalyzer,
private RetrieverInterface $retriever, private RetrieverInterface $retriever,
private ShopSearchService $shopSearchService,
private CommerceIntentLite $commerceIntentLite,
private OllamaClient $ollamaClient, private OllamaClient $ollamaClient,
private LoggerInterface $agentLogger, private LoggerInterface $agentLogger,
private bool $debug, private bool $debug,
@@ -33,6 +37,7 @@ final readonly class AgentRunner
public function run(string $prompt, string $userId, ?bool $includeFullContext = false): Generator public function run(string $prompt, string $userId, ?bool $includeFullContext = false): Generator
{ {
$prompt = trim($prompt); $prompt = trim($prompt);
$shopResults = [];
if ($prompt === '') { if ($prompt === '') {
yield '❌ Empty prompt.'; yield '❌ Empty prompt.';
@@ -57,16 +62,46 @@ final readonly class AgentRunner
// --------------------------------------------------------- // ---------------------------------------------------------
// 3) Retrieve RAG knowledge // 3) Retrieve RAG knowledge
// --------------------------------------------------------- // ---------------------------------------------------------
yield "Hole Daten aus dem RAG Wissen... \n";
$knowledgeChunks = $this->retriever->retrieve($prompt); $knowledgeChunks = $this->retriever->retrieve($prompt);
// --------------------------------------------------------- // ---------------------------------------------------------
// 4) Build final prompt // 4) Optional commerce/shop search
// ---------------------------------------------------------
$commerceMeta = $this->commerceIntentLite->detect($prompt);
$commerceIntent = (string) ($commerceMeta['intent'] ?? CommerceIntentLite::NONE);
if($commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH || $commerceIntent === CommerceIntentLite::PRODUCT_SEARCH){
yield "Rufe Shop auf (type: ".$commerceIntent.")... \n";
$shopResults = $this->shopSearchService->search($prompt,$commerceIntent);
}
if ($commerceIntent === CommerceIntentLite::PRODUCT_SEARCH) {
$knowledgeChunks = array_slice($knowledgeChunks, 0, 2);
} elseif ($commerceIntent === CommerceIntentLite::ADVISORY_PRODUCT_SEARCH) {
$knowledgeChunks = array_slice($knowledgeChunks, 0, 3);
}
if($shopResults){
yield "Verarbeite Shopdaten... \n<hr>\n";
}else{
yield "Keine releveanten Shopdaten gefunden... \n<hr>\n";
}
yield "Denke nach...\n<hr>\n";
// ---------------------------------------------------------
// 5) Build final prompt
// --------------------------------------------------------- // ---------------------------------------------------------
$finalPrompt = $this->promptBuilder->build( $finalPrompt = $this->promptBuilder->build(
prompt: $prompt, prompt: $prompt,
userId: $userId, userId: $userId,
urlContent: $urlContent, urlContent: $urlContent,
knowledgeChunks: $knowledgeChunks, knowledgeChunks: $knowledgeChunks,
shopResults: $shopResults,
fullContext: $includeFullContext fullContext: $includeFullContext
); );
@@ -84,7 +119,7 @@ final readonly class AgentRunner
} }
// --------------------------------------------------------- // ---------------------------------------------------------
// 5) Stream tokens from the LLM backend (chunked streaming) // 6) Stream tokens from the LLM backend (chunked streaming)
// --------------------------------------------------------- // ---------------------------------------------------------
$fullOutput = ''; $fullOutput = '';
$chunker = new StreamChunker(); $chunker = new StreamChunker();
@@ -120,7 +155,7 @@ final readonly class AgentRunner
} }
// --------------------------------------------------------- // ---------------------------------------------------------
// 6) Persist conversation history // 7) Persist conversation history
// --------------------------------------------------------- // ---------------------------------------------------------
$this->contextService->appendHistory( $this->contextService->appendHistory(
$userId, $userId,
@@ -132,6 +167,8 @@ final readonly class AgentRunner
'userId' => $userId, 'userId' => $userId,
'outputLength' => mb_strlen($fullOutput), 'outputLength' => mb_strlen($fullOutput),
'contextMode' => 'recent', 'contextMode' => 'recent',
'commerceIntent' => $commerceIntent,
'shopResultsCount' => count($shopResults),
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {
$this->agentLogger->error('Agent run failed', [ $this->agentLogger->error('Agent run failed', [

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Agent; namespace App\Agent;
use App\Commerce\Dto\ShopProductResult;
use App\Context\ContextService; use App\Context\ContextService;
use App\Repository\SystemPromptRepository; use App\Repository\SystemPromptRepository;
use DateTimeImmutable; use DateTimeImmutable;
@@ -24,6 +25,7 @@ final readonly class PromptBuilder
* @param string $userId * @param string $userId
* @param string $urlContent * @param string $urlContent
* @param string[] $knowledgeChunks * @param string[] $knowledgeChunks
* @param ShopProductResult[] $shopResults
* @param bool $fullContext * @param bool $fullContext
* @return string * @return string
*/ */
@@ -32,6 +34,7 @@ final readonly class PromptBuilder
string $userId, string $userId,
string $urlContent, string $urlContent,
array $knowledgeChunks, array $knowledgeChunks,
array $shopResults = [],
?bool $fullContext = false, ?bool $fullContext = false,
): string ): string
{ {
@@ -69,7 +72,59 @@ final readonly class PromptBuilder
} }
// ------------------------------------------------------------ // ------------------------------------------------------------
// 3) EXTERNAL KNOWLEDGE (SUPPORTING) // 3) LIVE SHOP RESULTS (AUTHORITATIVE FOR PRODUCTS)
// ------------------------------------------------------------
$shopBlock = '';
if ($shopResults !== []) {
$lines = [];
foreach ($shopResults as $i => $product) {
if (!$product instanceof ShopProductResult) {
continue;
}
$n = $i + 1;
$parts = [
"[{$n}] " . $product->name,
];
if ($product->productNumber) {
$parts[] = "Product number: " . $product->productNumber;
}
if ($product->manufacturer) {
$parts[] = "Manufacturer: " . $product->manufacturer;
}
if ($product->price) {
$parts[] = "Price: " . $product->price;
}
if ($product->available !== null) {
$parts[] = "Available: " . ($product->available ? 'yes' : 'no');
}
foreach ($product->highlights as $highlight) {
$parts[] = "- " . $highlight;
}
if ($product->url) {
$parts[] = "URL: " . $product->url;
}
$lines[] = implode("\n", $parts);
}
if ($lines !== []) {
$shopBlock =
"LIVE SHOP RESULTS (authoritative for products):\n" .
implode("\n\n", $lines);
}
}
// ------------------------------------------------------------
// 4) EXTERNAL KNOWLEDGE (SUPPORTING)
// ------------------------------------------------------------ // ------------------------------------------------------------
$knowledgeParts = []; $knowledgeParts = [];
@@ -98,18 +153,19 @@ final readonly class PromptBuilder
} }
// ------------------------------------------------------------ // ------------------------------------------------------------
// 4) USER QUESTION // 5) USER QUESTION
// ------------------------------------------------------------ // ------------------------------------------------------------
$userBlock = $userBlock =
"USER QUESTION:\n" . "USER QUESTION:\n" .
$prompt; $prompt;
// ------------------------------------------------------------ // ------------------------------------------------------------
// 5) FINAL PROMPT ASSEMBLY // 6) FINAL PROMPT ASSEMBLY
// ------------------------------------------------------------ // ------------------------------------------------------------
$blocks = array_filter([ $blocks = array_filter([
$systemBlock, $systemBlock,
$contextBlock, $contextBlock,
$shopBlock,
$knowledgeBlock, $knowledgeBlock,
$userBlock, $userBlock,
]); ]);

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Commerce\ShopSearchService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'mto:agent:test:shop-search')]
final class TestShopSearchCommand extends Command
{
public function __construct(
private readonly ShopSearchService $shopSearchService,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument(
'query',
InputArgument::OPTIONAL,
'Die zu testende Suchanfrage',
'zeige mir testomat modelle wasserhärte unter 5000 euro'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$query = (string) $input->getArgument('query');
$output->writeln('Test query: ' . $query);
$output->writeln('');
$results = $this->shopSearchService->search($query);
if ($results === []) {
$output->writeln('Keine Shop-Ergebnisse gefunden.');
return Command::SUCCESS;
}
foreach ($results as $index => $result) {
$n = $index + 1;
$output->writeln(sprintf('[%d] %s', $n, $result->name));
$output->writeln(' ID: ' . $result->id);
$output->writeln(' Produktnummer: ' . ($result->productNumber ?? '-'));
$output->writeln(' Hersteller: ' . ($result->manufacturer ?? '-'));
$output->writeln(' Preis: ' . ($result->price ?? '-'));
$output->writeln(' Verfügbar: ' . ($result->available === null ? '-' : ($result->available ? 'ja' : 'nein')));
$output->writeln(' URL: ' . ($result->url ?? '-'));
$output->writeln(' Description: ' . ($result->description ?? '-'));
if ($result->highlights !== []) {
$output->writeln(' Highlights:');
foreach ($result->highlights as $highlight) {
$output->writeln(' - ' . $highlight);
}
}
$output->writeln('');
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
namespace App\Commerce;
use App\Commerce\Dto\CommerceSearchQuery;
final class CommerceQueryParser
{
/**
* @var string[]
*/
private array $knownColors = [
'schwarz',
'weiß',
'weiss',
'rot',
'blau',
'grün',
'gruen',
'gelb',
'grau',
'beige',
'rosa',
'pink',
'orange',
'braun',
];
/**
* @var string[]
*/
private array $knownCategories = [
'sneaker',
'schuhe',
'hoodie',
't-shirt',
'shirt',
'jacke',
'regenjacke',
'trinkflasche',
'flasche',
'rucksack',
'tasche',
'mütze',
'muetze',
'kappe',
'hose',
'pullover',
];
/**
* @var string[]
*/
private array $knownBrands = [
'nike',
'adidas',
'puma',
'reebok',
'under armour',
'new balance',
];
public function parse(string $originalPrompt, string $intent): CommerceSearchQuery
{
$normalized = $this->normalize($originalPrompt);
[$priceMin, $priceMax] = $this->extractPriceRange($normalized);
$sizes = $this->extractSizes($normalized);
$colors = $this->extractColors($normalized);
$brand = $this->extractBrand($normalized);
$category = $this->extractCategory($normalized);
$properties = [];
$searchText = $this->buildSearchText(
$normalized,
$colors,
$sizes,
$brand,
$priceMin,
$priceMax
);
return new CommerceSearchQuery(
originalPrompt: $originalPrompt,
normalizedPrompt: $normalized,
searchText: $searchText !== '' ? $searchText : $normalized,
category: $category,
brand: $brand,
colors: $colors,
sizes: $sizes,
properties: $properties,
priceMin: $priceMin,
priceMax: $priceMax,
intent: $intent,
needsLlmFallback: false,
);
}
private function normalize(string $prompt): string
{
$value = mb_strtolower(trim($prompt));
$value = str_replace(['€'], ' euro ', $value);
$value = preg_replace('/[^\p{L}\p{N}\s.,\-]/u', ' ', $value) ?? $value;
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
return trim($value);
}
/**
* @return array{0:?float,1:?float}
*/
private function extractPriceRange(string $prompt): array
{
$priceMin = 10;
$priceMax = null;
if (preg_match('/\bzwischen\s+(\d+(?:[.,]\d+)?)\s+und\s+(\d+(?:[.,]\d+)?)\s+euro\b/u', $prompt, $m) === 1) {
$a = $this->toFloat($m[1]);
$b = $this->toFloat($m[2]);
if ($a !== null && $b !== null) {
return [min($a, $b), max($a, $b)];
}
}
if (preg_match('/\b(?:unter|bis|max(?:imal)?)\s+(\d+(?:[.,]\d+)?)\s+euro\b/u', $prompt, $m) === 1) {
$priceMax = $this->toFloat($m[1]);
}
if (preg_match('/\b(?:ab|mindestens|min)\s+(\d+(?:[.,]\d+)?)\s+euro\b/u', $prompt, $m) === 1) {
$priceMin = $this->toFloat($m[1]);
}
return [$priceMin, $priceMax];
}
/**
* @return string[]
*/
private function extractSizes(string $prompt): array
{
$sizes = [];
if (preg_match_all('/\b(?:größe|groesse|grösse)\s*([a-z0-9.-]+)\b/u', $prompt, $matches) === false) {
return [];
}
foreach ($matches[1] as $size) {
$sizes[] = trim($size);
}
if (preg_match_all('/\b(xs|s|m|l|xl|xxl|xxxl)\b/u', $prompt, $tokenMatches) !== false) {
foreach ($tokenMatches[1] as $sizeToken) {
$sizes[] = trim($sizeToken);
}
}
return array_values(array_unique(array_filter($sizes, static fn ($v) => $v !== '')));
}
/**
* @return string[]
*/
private function extractColors(string $prompt): array
{
$colors = [];
foreach ($this->knownColors as $color) {
if (preg_match('/\b' . preg_quote($color, '/') . '\b/u', $prompt) === 1) {
$colors[] = $color;
}
}
return array_values(array_unique($colors));
}
private function extractBrand(string $prompt): ?string
{
foreach ($this->knownBrands as $brand) {
if (str_contains($prompt, $brand)) {
return $brand;
}
}
if (preg_match('/\bmarke\s+([a-z0-9][a-z0-9\s\-]+)/u', $prompt, $m) === 1) {
return trim($m[1]);
}
return null;
}
private function extractCategory(string $prompt): ?string
{
foreach ($this->knownCategories as $category) {
if (preg_match('/\b' . preg_quote($category, '/') . '\b/u', $prompt) === 1) {
return $category;
}
}
return null;
}
private function buildSearchText(
string $prompt,
array $colors,
array $sizes,
?string $brand,
?float $priceMin,
?float $priceMax
): string {
$text = ' ' . $prompt . ' ';
$phrasesToRemove = [
'ich suche',
'suche',
'habt ihr',
'gibt es',
'zeige mir',
'welches gerät',
'welche gerät',
'welches modell',
'welches ist besser',
'welches ist am besten',
'alternative',
'alternativen',
];
foreach ($phrasesToRemove as $phrase) {
$text = str_replace($phrase, ' ', $text);
}
foreach ($colors as $color) {
$text = preg_replace('/\b' . preg_quote($color, '/') . '\b/u', ' ', $text) ?? $text;
}
foreach ($sizes as $size) {
$text = preg_replace('/\b' . preg_quote($size, '/') . '\b/u', ' ', $text) ?? $text;
}
if ($brand !== null && $brand !== '') {
$text = str_replace($brand, ' ', $text);
}
if ($priceMin !== null || $priceMax !== null) {
if ($priceMin !== null || $priceMax !== null) {
$text = preg_replace('/\bzwischen\s+\d+(?:[.,]\d+)?\s+und\s+\d+(?:[.,]\d+)?\s*euro\b/u', ' ', $text) ?? $text;
$text = preg_replace('/\b(?:unter|bis|max(?:imal)?|ab|mindestens|min)\s+\d+(?:[.,]\d+)?\s*euro\b/u', ' ', $text) ?? $text;
$text = preg_replace('/\beuro\b/u', ' ', $text) ?? $text;
}
}
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
$text = trim($text, " \t\n\r\0\x0B-.,");
$tokens = array_filter(explode(' ', $text), static fn (string $token): bool => mb_strlen($token) > 1);
return trim(implode(' ', $tokens));
}
private function toFloat(string $value): ?float
{
$value = str_replace(',', '.', trim($value));
return is_numeric($value) ? (float) $value : null;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Commerce\Dto;
final readonly class CommerceSearchQuery
{
/**
* @param string[] $colors
* @param string[] $sizes
* @param string[] $properties
*/
public function __construct(
public string $originalPrompt,
public string $normalizedPrompt,
public string $searchText,
public ?string $category = null,
public ?string $brand = null,
public array $colors = [],
public array $sizes = [],
public array $properties = [],
public ?float $priceMin = null,
public ?float $priceMax = null,
public string $intent = 'none',
public bool $needsLlmFallback = false,
) {
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Commerce\Dto;
final readonly class ShopProductResult
{
/**
* @param string[] $highlights
*/
public function __construct(
public string $id,
public string $name,
public ?string $productNumber = null,
public ?string $manufacturer = null,
public ?string $price = null,
public ?bool $available = null,
public ?string $url = null,
public array $highlights = [],
public ?string $description = null
) {
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Commerce;
use App\Commerce\Dto\ShopProductResult;
use App\Shopware\ShopwareCriteriaBuilder;
use App\Shopware\StoreApiClient;
final readonly class ShopSearchService
{
public function __construct(
private CommerceQueryParser $queryParser,
private ShopwareCriteriaBuilder $criteriaBuilder,
private StoreApiClient $storeApiClient,
private bool $enabled = true,
private int $maxResults = 25,
private string $baseUrl
)
{
}
/**
* @return ShopProductResult[]
*/
public function search(string $originalPrompt, string $commerceIntent): array
{
if (!$this->enabled) {
return [];
}
$query = $this->queryParser->parse($originalPrompt, $commerceIntent);
$criteria = $this->criteriaBuilder->build($query, $this->maxResults);
$response = $this->storeApiClient->searchProducts($criteria);
return $this->mapProducts($response);
}
/**
* @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;
}
$results[] = new ShopProductResult(
id: (string)($row['id'] ?? ''),
name: trim((string)($row['translated']['name'] ?? '')),
productNumber: isset($row['productNumber']) ? (string)$row['productNumber'] : null,
price: $this->extractPrice($row),
available: isset($row['available']) ? (bool)$row['available'] : null,
url: $this->baseUrl . $this->extractUrl($row),
highlights: $this->extractHighlights($row),
description: $this->cleanUpDescription($row),
);
}
return array_values(array_filter(
$results,
static fn(ShopProductResult $product): bool => $product->name !== ''
));
}
private function cleanUpDescription($description): string
{
if (isset($description['translated']['description'])) {
$newDesc = strip_tags((string)$description['translated']['description']);
$newDesc = preg_replace('/^[ \t]*\R/m', '', $newDesc); // leere Zeilen weg
$newDesc = preg_replace('/[ \t]{2,}/', ' ', $newDesc); // mehrere Spaces zu einem
$result = trim($newDesc);
return substr($result, 0, 500);
}
return '';
}
private function extractManufacturer(array $row): ?string
{
$manufacturer = $row['manufacturer'] ?? null;
if (is_array($manufacturer) && isset($manufacturer['name']) && is_string($manufacturer['name'])) {
return trim($manufacturer['name']) !== '' ? trim($manufacturer['name']) : null;
}
return null;
}
private function extractPrice(array $row): ?string
{
$calculatedPrice = $row['calculatedPrice'] ?? null;
if (!is_array($calculatedPrice)) {
return null;
}
$unitPrice = $calculatedPrice['unitPrice'] ?? $calculatedPrice['totalPrice'] ?? $calculatedPrice['referencePrice'] ?? $calculatedPrice['listPrice'] ?? $calculatedPrice['regulationPrice'] ?? 0;
if (!is_numeric($unitPrice)) {
return null;
}
return number_format((float)$unitPrice, 2, ',', '.') . ' €';
}
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;
}
/**
* @return string[]
*/
private function extractHighlights(array $row): array
{
$highlights = [];
if (isset($row['available'])) {
$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));
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Intent;
final class CommerceIntentLite
{
public const NONE = 'none';
public const PRODUCT_SEARCH = 'product_search';
public const ADVISORY_PRODUCT_SEARCH = 'advisory_product_search';
/**
* @return array{intent:string, score:int, signals:string[]}
*/
public function detect(string $originalPrompt): array
{
$p = mb_strtolower(trim($originalPrompt));
if ($p === '') {
return [
'intent' => self::NONE,
'score' => 0,
'signals' => [],
];
}
$score = 0;
$signals = [];
$strongSignals = [
'suche',
'habt',
'gibt',
'zeig',
'welche',
'vergleich',
'alternativ',
'find',
'shop',
'sku',
'Artikel',
'Gerät'
];
foreach ($strongSignals as $signal) {
if (str_contains($p, $signal)) {
$score += 2;
$signals[] = $signal;
}
}
if(preg_match('#\d{3,10}#', $p)){
$score += 2;
$signals[] = 'sku';
}
if (preg_match('/\b\d+(?:[.,]\d+)?\s*(euro|€|eur|teuer|preis|kosten)\b/u', $p) === 1) {
$score += 2;
$signals[] = 'price';
}
if (preg_match('/\b(größe|groesse|grösse)\s*[a-z0-9.-]+\b/u', $p) === 1) {
$score += 2;
$signals[] = 'size';
}
if (preg_match('/\b(xs|s|m|l|xl|xxl|xxxl)\b/u', $p) === 1) {
$score += 1;
$signals[] = 'size_token';
}
if (preg_match('/\b(schwarz|weiß|weiss|rot|blau|grün|gruen|gelb|grau|beige|rosa|pink|orange|braun)\b/u', $p) === 1) {
$score += 1;
$signals[] = 'color';
}
$advisorySignals = [
'passt',
'eignet',
'besser',
'besten',
'geeignet',
'empfiehl',
'empfehl',
];
foreach ($advisorySignals as $signal) {
if (str_contains($p, $signal)) {
$score += 1;
$signals[] = 'advisory:' . $signal;
}
}
$signals = array_values(array_unique($signals));
if ($score >= 3) {
return [
'intent' => self::PRODUCT_SEARCH,
'score' => $score,
'signals' => $signals,
];
}
if ($score >= 2) {
return [
'intent' => self::ADVISORY_PRODUCT_SEARCH,
'score' => $score,
'signals' => $signals,
];
}
return [
'intent' => self::NONE,
'score' => $score,
'signals' => $signals,
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Knowledge\Retrieval;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
final readonly class CachedRetriever implements RetrieverInterface
{
public function __construct(
private RetrieverInterface $inner,
private CacheItemPoolInterface $cache,
private int $ttlSeconds = 300,
) {
}
/**
* @return string[]
* @throws InvalidArgumentException
*/
public function retrieve(string $prompt): array
{
$key = $this->buildCacheKey($prompt);
$item = $this->cache->getItem($key);
if ($item->isHit()) {
$cached = $item->get();
return is_array($cached) ? $cached : [];
}
$result = $this->inner->retrieve($prompt);
$item->set($result);
$item->expiresAfter($this->ttlSeconds);
$this->cache->save($item);
return $result;
}
private function buildCacheKey(string $prompt): string
{
$normalized = mb_strtolower(trim($prompt));
$normalized = preg_replace('/\s+/u', ' ', $normalized) ?? $normalized;
return 'rag_retrieval_' . sha1($normalized);
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Shopware;
use App\Commerce\Dto\CommerceSearchQuery;
final class ShopwareCriteriaBuilder
{
public function build(CommerceSearchQuery $query, int $limit = 25): array
{
$criteria = [
'page' => 1,
'limit' => max(1, $limit),
"grouping" => ["parentId"],
'total-count-mode' => 0,
'includes' => [
'product' => [
'id',
'name',
'description',
'productNumber',
'available',
'calculatedPrice',
'seoUrls',
'manufacturer',
'translated.name'
],
'product_manufacturer' => [
'name',
],
'calculated_price' => [
'unitPrice',
'totalPrice',
'referencePrice',
'listPrice',
'regulationPrice'
],
'seo_url' => [
'seoPathInfo',
],
],
'associations' => [
'manufacturer' => new \stdClass(),
'seoUrls' => new \stdClass(),
],
'sort' => [
[
'field' => 'name',
'order' => 'ASC',
'naturalSorting' => true,
],
],
];
if ($query->searchText !== '') {
$criteria['term'] = $query->searchText;
}
$filters = [
[
'type' => 'equals',
'field' => 'active',
'value' => true,
],
[
'type' => 'equals',
'field' => 'available',
'value' => true,
],
[
'type' => 'range',
'field' => 'price.gross',
'parameters' => [
'gt' => 0,
],
]
];
if ($query->priceMin !== null) {
$criteria['min-price'] = $query->priceMin;
}
if ($query->priceMax !== null) {
$criteria['max-price'] = $query->priceMax;
}
$criteria['filter'] = $filters;
return $criteria;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Shopware;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class StoreApiClient
{
public function __construct(
private HttpClientInterface $httpClient,
private string $baseUrl,
private string $salesChannelAccessKey,
private int $timeoutSeconds = 5,
)
{
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
*/
public function searchProducts(array $criteria): array
{
$url = rtrim($this->baseUrl, '/') . '/store-api/product';
$response = $this->httpClient->request('POST', $url, [
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'sw-access-key' => $this->salesChannelAccessKey,
],
'json' => $criteria,
'timeout' => $this->timeoutSeconds,
]);
$statusCode = $response->getStatusCode();
if ($statusCode < 200 || $statusCode >= 300) {
return [];
}
return $response->toArray(false);
}
}

View File

@@ -11,7 +11,7 @@
<div class="card-body"> <div class="card-body">
<h1 class="h4 mb-4 text-center text-info"> <h1 class="h4 mb-4 text-center text-info">
mitho® KI RAG Login Heyl-Neomeris KI/RAG Login
</h1> </h1>
{% if error %} {% if error %}