optimize data retrieve by customfields und enricher

This commit is contained in:
team 1
2026-04-13 16:11:19 +02:00
parent 0a05ccaee3
commit f7685c6fb5
10 changed files with 234 additions and 32 deletions

View File

@@ -410,3 +410,8 @@ span.think {
animation: none; animation: none;
} }
} }
.bubble img {
max-width: 50px;
max-height: 50px;
}

View File

@@ -18,7 +18,8 @@
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<h1>mitho® KI-Agent</h1> <h1>RetrieX KI-Agent</h1>
<div class="small">powered by mitho®</div>
<div class="spacer"></div> <div class="spacer"></div>
<button id="clear" class="btn btn-trans">Diesen Chat löschen</button> <button id="clear" class="btn btn-trans">Diesen Chat löschen</button>
</div> </div>

View File

@@ -58,7 +58,7 @@ final readonly class AgentRunner
//$includeFullContext = false; //$includeFullContext = false;
if ($includeFullContext) { if ($includeFullContext) {
yield $this->systemMsg("Ich analyse deine Anfrage...", "think"); yield $this->systemMsg("Ich analysiere deine Anfrage...", "think");
$promptSwagSearch = ' $promptSwagSearch = '
Erzeuge aus dem folgenden Nutzereingabetext einen kurzen Suchtext für die Shopware-6-Suche. Erzeuge aus dem folgenden Nutzereingabetext einen kurzen Suchtext für die Shopware-6-Suche.
@@ -80,6 +80,7 @@ final readonly class AgentRunner
'; ';
$this->thinkSuppressor->reset(); $this->thinkSuppressor->reset();
foreach ($this->ollamaClient->stream($promptSwagSearch) as $swagToken) { foreach ($this->ollamaClient->stream($promptSwagSearch) as $swagToken) {
if (!is_string($swagToken)) { if (!is_string($swagToken)) {
@@ -138,7 +139,6 @@ final readonly class AgentRunner
yield $this->systemMsg("Denke nach...", "think"); yield $this->systemMsg("Denke nach...", "think");
// --------------------------------------------------------- // ---------------------------------------------------------
// 5) Build final prompt // 5) Build final prompt
// --------------------------------------------------------- // ---------------------------------------------------------

View File

@@ -75,6 +75,7 @@ final readonly class PromptBuilder
// 3) LIVE SHOP RESULTS (AUTHORITATIVE FOR PRODUCTS) // 3) LIVE SHOP RESULTS (AUTHORITATIVE FOR PRODUCTS)
// ------------------------------------------------------------ // ------------------------------------------------------------
$shopBlock = ''; $shopBlock = '';
$isDetailed = !(count($shopResults) > 5);
if ($shopResults !== []) { if ($shopResults !== []) {
$lines = []; $lines = [];
@@ -113,6 +114,18 @@ final readonly class PromptBuilder
$parts[] = "URL: " . $product->url; $parts[] = "URL: " . $product->url;
} }
if ($product->productImage) {
$parts[] = "productImage: " . $product->productImage;
}
if ($isDetailed && $product->description) {
$parts[] = "description: " . $product->description;
}
if ($product->customFields) {
$parts[] = "Meta-Informationen: " . $product->customFields;
}
$lines[] = implode("\n", $parts); $lines[] = implode("\n", $parts);
} }

View File

@@ -10,15 +10,18 @@ final readonly class ShopProductResult
* @param string[] $highlights * @param string[] $highlights
*/ */
public function __construct( public function __construct(
public string $id, public string $id,
public string $name, public string $name,
public ?string $productNumber = null, public ?string $productNumber = null,
public ?string $manufacturer = null, public ?string $manufacturer = null,
public ?string $price = null, public ?string $price = null,
public ?bool $available = null, public ?bool $available = null,
public ?string $url = null, public ?string $url = null,
public array $highlights = [], public array $highlights = [],
public ?string $description = null public ?string $description = null,
) { public ?string $productImage = null,
public ?string $customFields = null,
)
{
} }
} }

View File

@@ -7,6 +7,11 @@ namespace App\Commerce;
use App\Commerce\Dto\ShopProductResult; use App\Commerce\Dto\ShopProductResult;
use App\Shopware\ShopwareCriteriaBuilder; use App\Shopware\ShopwareCriteriaBuilder;
use App\Shopware\StoreApiClient; use App\Shopware\StoreApiClient;
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;
final readonly class ShopSearchService final readonly class ShopSearchService
{ {
@@ -29,12 +34,18 @@ final readonly class ShopSearchService
if (!$this->enabled) { if (!$this->enabled) {
return []; return [];
} }
$response = [];
$query = $this->queryParser->parse($originalPrompt, $commerceIntent); $query = $this->queryParser->parse($originalPrompt, $commerceIntent);
$criteria = $this->criteriaBuilder->build($query, $this->maxResults); $criteria = $this->criteriaBuilder->build($query, $this->maxResults);
$response = $this->storeApiClient->searchProducts($criteria); try {
$response = $this->storeApiClient->searchProducts($criteria);
} catch (ClientExceptionInterface|DecodingExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $e) {
return $this->mapProducts($response); }
$result = $this->mapProducts($response);;
return $result;
} }
/** /**
@@ -63,6 +74,8 @@ final readonly class ShopSearchService
url: $this->baseUrl . $this->extractUrl($row), url: $this->baseUrl . $this->extractUrl($row),
highlights: $this->extractHighlights($row), highlights: $this->extractHighlights($row),
description: $this->cleanUpDescription($row), description: $this->cleanUpDescription($row),
productImage: $row['cover']['media']['thumbnails'][0]['url'] ?? 'no-image',
customFields: $this->getRelevantCustomFields($row['customFields'])
); );
} }
@@ -72,6 +85,15 @@ final readonly class ShopSearchService
)); ));
} }
private function getRelevantCustomFields($customField): string
{
$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 $result;
}
private function cleanUpDescription($description): string private function cleanUpDescription($description): string
{ {
if (isset($description['translated']['description'])) { if (isset($description['translated']['description'])) {

View File

@@ -41,8 +41,11 @@ final class NdjsonHybridRetriever implements RetrieverInterface
private readonly SalesIntentLite $salesIntentLite, private readonly SalesIntentLite $salesIntentLite,
private readonly CatalogIntentLite $catalogIntent, private readonly CatalogIntentLite $catalogIntent,
private readonly IntentRouteResolver $routeResolver, private readonly IntentRouteResolver $routeResolver,
private readonly EntityCatalogService $entityCatalogService private readonly EntityCatalogService $entityCatalogService,
) {} private readonly QueryEnricher $queryEnricher,
)
{
}
// ========================================================= // =========================================================
// PUBLIC API // PUBLIC API
@@ -126,10 +129,11 @@ final class NdjsonHybridRetriever implements RetrieverInterface
// ========================================================= // =========================================================
private function execute( private function execute(
string $prompt, string $prompt,
ModelGenerationConfig $config, ModelGenerationConfig $config,
bool $withScores bool $withScores
): array { ): array
{
$entityLabel = $this->catalogIntent->detect($prompt); $entityLabel = $this->catalogIntent->detect($prompt);
$salesIntent = $this->detectSalesIntent($prompt); $salesIntent = $this->detectSalesIntent($prompt);
@@ -195,11 +199,12 @@ final class NdjsonHybridRetriever implements RetrieverInterface
// ========================================================= // =========================================================
private function runCore( private function runCore(
string $prompt, string $prompt,
ModelGenerationConfig $config, ModelGenerationConfig $config,
bool $withScores, bool $withScores,
string $salesIntent string $salesIntent
): array { ): array
{
$limit = max(1, min($config->getRetrievalMaxChunks(), self::HARD_MAX_CHUNKS)); $limit = max(1, min($config->getRetrievalMaxChunks(), self::HARD_MAX_CHUNKS));
$vectorTopKBase = max(1, min($config->getRetrievalVectorTopK(), self::HARD_MAX_VECTORK)); $vectorTopKBase = max(1, min($config->getRetrievalVectorTopK(), self::HARD_MAX_VECTORK));
@@ -207,6 +212,7 @@ final class NdjsonHybridRetriever implements RetrieverInterface
$isListQuery = $this->intentLite->isListQuery($prompt); $isListQuery = $this->intentLite->isListQuery($prompt);
$cleanQuery = $this->queryCleaner->clean($prompt); $cleanQuery = $this->queryCleaner->clean($prompt);
$cleanQuery = $this->queryEnricher->enrichPrompt($cleanQuery);
if ($cleanQuery === '') { if ($cleanQuery === '') {
return [ return [
@@ -316,9 +322,10 @@ final class NdjsonHybridRetriever implements RetrieverInterface
private function computeThresholdAndTopK( private function computeThresholdAndTopK(
string $salesIntent, string $salesIntent,
bool $isListQuery, bool $isListQuery,
int $vectorTopKBase int $vectorTopKBase
): array { ): array
{
$threshold = self::VECTOR_SCORE_THRESHOLD; $threshold = self::VECTOR_SCORE_THRESHOLD;
$topK = $vectorTopKBase; $topK = $vectorTopKBase;
@@ -344,9 +351,10 @@ final class NdjsonHybridRetriever implements RetrieverInterface
array $globalHits, array $globalHits,
array $scopedHits, array $scopedHits,
float $threshold, float $threshold,
bool $boostScoped, bool $boostScoped,
bool $captureRaw bool $captureRaw
): array { ): array
{
$rrfScores = []; $rrfScores = [];
$rawScores = []; $rawScores = [];

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Knowledge\Retrieval;
final class QueryEnricher
{
public function enrichPrompt(string $query): string
{
// Return early if the input is empty or contains only whitespace.
if (trim($query) === '') {
return '';
}
// Keep the original query untouched for the final output.
$originalQuery = $query;
// Normalize the query for case-insensitive matching.
$normalizedQuery = $this->normalize($query);
// Expect an associative array like:
// [
// 'hose' => 'jeans',
// 'jacke' => 'mantel',
// ]
$mapping = $this->enrichQueryList();
// Build a bidirectional lookup table:
// key -> value
// value -> key
$lookup = $this->buildBidirectionalLookup($mapping);
// Split the query into searchable words/tokens.
$tokens = $this->tokenize($normalizedQuery);
$matches = [];
foreach ($tokens as $token) {
// If the token exists in the lookup table, add the mapped counterpart.
if (isset($lookup[$token])) {
$matches[] = $lookup[$token];
}
}
// Remove duplicates while preserving order.
$matches = array_values(array_unique($matches));
// If nothing was found, return the original query unchanged.
if ($matches === []) {
return $originalQuery;
}
// Append the matched counterpart terms to the original prompt.
return $originalQuery . " | Pseudonyme: " . implode(', ', $matches);
}
/**
* Normalize a string for case-insensitive comparison.
*/
private function normalize(string $value): string
{
return mb_strtolower(trim($value), 'UTF-8');
}
/**
* Tokenize the query into words.
* Splits on everything that is not a letter or number.
*/
private function tokenize(string $value): array
{
return preg_split('/[^\p{L}\p{N}]+/u', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
}
/**
* Build a lookup table that works in both directions.
*
* Example:
* [
* 'hose' => 'jeans',
* 'jacke' => 'mantel',
* ]
*
* becomes:
* [
* 'hose' => 'jeans',
* 'jeans' => 'hose',
* 'jacke' => 'mantel',
* 'mantel' => 'jacke',
* ]
*/
private function buildBidirectionalLookup(array $mapping): array
{
$lookup = [];
foreach ($mapping as $key => $value) {
$key = trim((string)$key);
$value = trim((string)$value);
// Skip incomplete pairs.
if ($key === '' || $value === '') {
continue;
}
$normalizedKey = $this->normalize($key);
$normalizedValue = $this->normalize($value);
// If the key is found in the query, return the value.
$lookup[$normalizedKey] = $value;
// If the value is found in the query, return the key.
$lookup[$normalizedValue] = $key;
}
return $lookup;
}
public function enrichQueryList(): array
{
return [
'Wasserhärte' => "Resthärte",
'Gerät' => 'Modell',
'Indikator' => 'Chemie',
'Wasserhärte-Grenzwert'=>'Resthärte',
'Resthärte-Grenzwert'=>'Wasserhärte'
];
}
}

View File

@@ -28,7 +28,9 @@ final class ShopwareCriteriaBuilder
'calculatedPrice', 'calculatedPrice',
'seoUrls', 'seoUrls',
'manufacturer', 'manufacturer',
'translated.name' 'translated.name',
'cover',
'customFields'
], ],
'product_manufacturer' => [ 'product_manufacturer' => [
'name', 'name',
@@ -43,10 +45,30 @@ final class ShopwareCriteriaBuilder
'seo_url' => [ 'seo_url' => [
'seoPathInfo', 'seoPathInfo',
], ],
'product_media' => [
'id',
'media'
],
'media' => [
'id',
'url',
'thumbnails',
'alt',
'title'
]
], ],
'associations' => [ 'associations' => [
'manufacturer' => new \stdClass(), 'manufacturer' => new \stdClass(),
'seoUrls' => new \stdClass(), 'seoUrls' => new \stdClass(),
'cover' => [
'associations' => [
'media' => [
'associations' => [
"thumbnails" => new \stdClass()
]
]
]
]
], ],
'sort' => [ 'sort' => [
[ [

View File

@@ -20,10 +20,10 @@
{# ============================= #} {# ============================= #}
<nav class="navbar navbar-dark bg-black border-bottom border-secondary px-3"> <nav class="navbar navbar-dark bg-black border-bottom border-secondary px-3">
<span class="navbar-brand fw-semibold text-info"> <div class="navbar-brand fw-semibold text-info">
mitho&reg; RAG-System RetrieX RAG-System
</span> </div>
<div class="small">powered by mitho®</div>
<div class="ms-auto d-flex align-items-center gap-3"> <div class="ms-auto d-flex align-items-center gap-3">
{% if app.user %} {% if app.user %}
<span class="small text-light"> <span class="small text-light">