optimize retrieval
This commit is contained in:
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Commerce;
|
||||
|
||||
use App\Commerce\Dto\CommerceReferenceContext;
|
||||
use App\Commerce\Dto\CommerceSearchQuery;
|
||||
use App\Config\CommerceIntentConfig;
|
||||
use App\Config\CommerceQueryParserConfig;
|
||||
@@ -23,10 +24,12 @@ final readonly class CommerceQueryParser
|
||||
public function parse(
|
||||
string $originalPrompt,
|
||||
string $intent,
|
||||
string $historyContext = ''
|
||||
string $historyContext = '',
|
||||
?CommerceReferenceContext $referenceContext = null
|
||||
): CommerceSearchQuery {
|
||||
$normalizedPrompt = $this->normalize($originalPrompt);
|
||||
$isDirectProductQuery = $this->isDirectProductQuery($normalizedPrompt);
|
||||
$isReferenceOnlyFollowUp = $this->isReferenceOnlyFollowUp($normalizedPrompt);
|
||||
|
||||
[$priceMin, $priceMax] = $this->extractPriceRange($normalizedPrompt);
|
||||
$sizes = $this->extractSizes($normalizedPrompt);
|
||||
@@ -44,7 +47,7 @@ final readonly class CommerceQueryParser
|
||||
if (
|
||||
!$isDirectProductQuery
|
||||
&& $historyContext !== ''
|
||||
&& $this->shouldUseHistoryContext($normalizedPrompt)
|
||||
&& $this->shouldUseHistoryContext($normalizedPrompt, $searchText)
|
||||
) {
|
||||
$latestHistoryQuestion = $this->extractLatestQuestionFromHistory($historyContext);
|
||||
|
||||
@@ -73,7 +76,29 @@ final readonly class CommerceQueryParser
|
||||
}
|
||||
}
|
||||
|
||||
$finalSearchText = $searchText !== '' ? $searchText : $normalizedPrompt;
|
||||
if (
|
||||
!$isDirectProductQuery
|
||||
&& $referenceContext !== null
|
||||
&& $this->shouldUseReferenceContext($normalizedPrompt, $searchText)
|
||||
) {
|
||||
$referenceSearchText = $this->buildReferenceSearchText($referenceContext);
|
||||
|
||||
if ($isReferenceOnlyFollowUp || $this->isTooGenericSearchText($searchText)) {
|
||||
$searchText = $referenceSearchText !== '' ? $referenceSearchText : $searchText;
|
||||
} else {
|
||||
$searchText = $this->mergeSearchTexts($referenceSearchText, $searchText);
|
||||
}
|
||||
|
||||
if (($brand === null || $brand === '') && $referenceContext->manufacturer !== null) {
|
||||
$normalizedManufacturer = $this->normalize($referenceContext->manufacturer);
|
||||
|
||||
if ($normalizedManufacturer !== '') {
|
||||
$brand = $normalizedManufacturer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$finalSearchText = trim($searchText !== '' ? $searchText : $normalizedPrompt);
|
||||
|
||||
return new CommerceSearchQuery(
|
||||
originalPrompt: $originalPrompt,
|
||||
@@ -93,7 +118,7 @@ final readonly class CommerceQueryParser
|
||||
{
|
||||
$value = $this->textNormalizer->normalize($prompt);
|
||||
$value = $this->queryCleaner->clean($value);
|
||||
$value = mb_strtolower(trim($value));
|
||||
$value = mb_strtolower(trim($value), 'UTF-8');
|
||||
$value = str_replace(['€'], ' euro ', $value);
|
||||
$value = preg_replace('/[^\p{L}\p{N}\s.,\-]/u', ' ', $value) ?? $value;
|
||||
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||
@@ -126,6 +151,17 @@ final readonly class CommerceQueryParser
|
||||
$priceMin = $this->toFloat($m[1]);
|
||||
}
|
||||
|
||||
// NEW:
|
||||
// Recognize comparative lower-bound phrasing such as:
|
||||
// - mehr als 3000 euro
|
||||
// - über 3000 euro
|
||||
// - ueber 3000 euro
|
||||
// - größer als 3000 euro
|
||||
// - groesser als 3000 euro
|
||||
if (preg_match('/\b(?:mehr\s+als|über|ueber|größer\s+als|groesser\s+als)\s+(\d+(?:[.,]\d+)?)\s+euro\b/u', $prompt, $m) === 1) {
|
||||
$priceMin = $this->toFloat($m[1]);
|
||||
}
|
||||
|
||||
return [$priceMin, $priceMax];
|
||||
}
|
||||
|
||||
@@ -152,7 +188,10 @@ final readonly class CommerceQueryParser
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($sizes, static fn($v) => $v !== '')));
|
||||
return array_values(array_unique(array_filter(
|
||||
$sizes,
|
||||
static fn(string $value): bool => $value !== ''
|
||||
)));
|
||||
}
|
||||
|
||||
private function extractBrand(string $prompt): ?string
|
||||
@@ -184,6 +223,7 @@ final readonly class CommerceQueryParser
|
||||
|
||||
foreach ($this->config->getPhrasesToRemove() as $phrase) {
|
||||
$normalizedPhrase = $this->normalize((string) $phrase);
|
||||
|
||||
if ($normalizedPhrase === '') {
|
||||
continue;
|
||||
}
|
||||
@@ -193,6 +233,7 @@ final readonly class CommerceQueryParser
|
||||
|
||||
foreach ($sizes as $size) {
|
||||
$normalizedSize = $this->normalize((string) $size);
|
||||
|
||||
if ($normalizedSize === '') {
|
||||
continue;
|
||||
}
|
||||
@@ -207,6 +248,7 @@ final readonly class CommerceQueryParser
|
||||
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('/\b(?:mehr\s+als|über|ueber|größer\s+als|groesser\s+als)\s+\d+(?:[.,]\d+)?\s*euro\b/u', ' ', $text) ?? $text;
|
||||
$text = preg_replace('/\b' . $this->intentConfig->getPricePattern() . '\b/u', ' ', $text) ?? $text;
|
||||
}
|
||||
|
||||
@@ -219,14 +261,14 @@ final readonly class CommerceQueryParser
|
||||
);
|
||||
|
||||
$tokens = $this->filterSearchTokens($tokens);
|
||||
$tokens = $this->stripReferenceOnlyTokens($tokens);
|
||||
|
||||
return trim(implode(' ', $tokens));
|
||||
}
|
||||
|
||||
private function buildDirectProductSearchText(string $prompt): string
|
||||
{
|
||||
$text = $prompt;
|
||||
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
|
||||
$text = preg_replace('/\s+/u', ' ', $prompt) ?? $prompt;
|
||||
$text = trim($text, " \t\n\r\0\x0B-.,");
|
||||
|
||||
$tokens = array_filter(
|
||||
@@ -234,17 +276,61 @@ final readonly class CommerceQueryParser
|
||||
static fn(string $token): bool => mb_strlen($token) > 0
|
||||
);
|
||||
|
||||
$tokens = array_values(array_unique($tokens));
|
||||
|
||||
return trim(implode(' ', $tokens));
|
||||
return trim(implode(' ', array_values(array_unique($tokens))));
|
||||
}
|
||||
|
||||
private function shouldUseHistoryContext(string $prompt): bool
|
||||
private function shouldUseHistoryContext(string $prompt, string $searchText): bool
|
||||
{
|
||||
return preg_match(
|
||||
'/\b(' . $this->config->getHistoryContextPattern() . ')\b/u',
|
||||
$prompt
|
||||
) === 1;
|
||||
if ($this->isReferenceOnlyFollowUp($prompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->isTooGenericSearchText($searchText)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return preg_match('/\b(' . $this->config->getHistoryContextPattern() . ')\b/u', $prompt) === 1;
|
||||
}
|
||||
|
||||
private function shouldUseReferenceContext(string $prompt, string $searchText): bool
|
||||
{
|
||||
if ($this->isReferenceOnlyFollowUp($prompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->isTooGenericSearchText($searchText);
|
||||
}
|
||||
|
||||
private function isReferenceOnlyFollowUp(string $prompt): bool
|
||||
{
|
||||
return preg_match('/\b(' . $this->config->getReferenceFollowUpPattern() . ')\b/u', $prompt) === 1;
|
||||
}
|
||||
|
||||
private function isTooGenericSearchText(string $searchText): bool
|
||||
{
|
||||
$tokens = array_values(array_filter(
|
||||
preg_split('/\s+/u', $searchText, -1, PREG_SPLIT_NO_EMPTY) ?: [],
|
||||
static fn(string $token): bool => $token !== ''
|
||||
));
|
||||
|
||||
if ($tokens === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$genericTokens = array_fill_keys($this->config->getReferenceOnlyTokens(), true);
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (!isset($genericTokens[$token])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function buildReferenceSearchText(CommerceReferenceContext $referenceContext): string
|
||||
{
|
||||
return $this->normalize($referenceContext->buildReferenceSearchText());
|
||||
}
|
||||
|
||||
private function extractLatestQuestionFromHistory(string $historyContext): string
|
||||
@@ -256,6 +342,7 @@ final readonly class CommerceQueryParser
|
||||
}
|
||||
|
||||
$questions = $matches[1] ?? [];
|
||||
|
||||
if ($questions === []) {
|
||||
return '';
|
||||
}
|
||||
@@ -265,11 +352,11 @@ final readonly class CommerceQueryParser
|
||||
return is_string($lastQuestion) ? trim($lastQuestion) : '';
|
||||
}
|
||||
|
||||
private function mergeSearchTexts(string $historySearchText, string $currentSearchText): string
|
||||
private function mergeSearchTexts(string $left, string $right): string
|
||||
{
|
||||
$tokens = [];
|
||||
|
||||
foreach ([$historySearchText, $currentSearchText] as $text) {
|
||||
foreach ([$left, $right] as $text) {
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
@@ -294,11 +381,25 @@ final readonly class CommerceQueryParser
|
||||
*/
|
||||
private function filterSearchTokens(array $tokens): array
|
||||
{
|
||||
$stopWords = $this->config->getFilterSearchTokensPattern();
|
||||
$stopWords = array_fill_keys($this->config->getFilterSearchTokensPattern(), true);
|
||||
|
||||
return array_values(array_filter(
|
||||
$tokens,
|
||||
static fn(string $token): bool => !in_array($token, $stopWords, true)
|
||||
static fn(string $token): bool => !isset($stopWords[$token])
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $tokens
|
||||
* @return string[]
|
||||
*/
|
||||
private function stripReferenceOnlyTokens(array $tokens): array
|
||||
{
|
||||
$referenceOnly = array_fill_keys($this->config->getReferenceOnlyTokens(), true);
|
||||
|
||||
return array_values(array_filter(
|
||||
$tokens,
|
||||
static fn(string $token): bool => !isset($referenceOnly[$token])
|
||||
));
|
||||
}
|
||||
|
||||
@@ -318,11 +419,7 @@ final readonly class CommerceQueryParser
|
||||
|
||||
$tokens = preg_split('/\s+/u', $prompt, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
|
||||
if (count($tokens) <= 4 && preg_match('/\d/u', $prompt) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return count($tokens) <= 4 && preg_match('/\d/u', $prompt) === 1;
|
||||
}
|
||||
|
||||
private function containsModelLikePhrase(string $text): bool
|
||||
|
||||
239
src/Commerce/CommerceReferenceResolver.php
Normal file
239
src/Commerce/CommerceReferenceResolver.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commerce;
|
||||
|
||||
use App\Commerce\Dto\CommerceReferenceContext;
|
||||
|
||||
final readonly class CommerceReferenceResolver
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $shopResults
|
||||
*/
|
||||
public function resolveFromCommerceTurn(
|
||||
string $prompt,
|
||||
string $answerText,
|
||||
array $shopResults
|
||||
): ?CommerceReferenceContext {
|
||||
$fromText = $this->resolveFromText($prompt, $answerText);
|
||||
$fromShop = $this->resolveFromShopResults($prompt, $shopResults);
|
||||
|
||||
if ($fromText !== null && $fromShop !== null && $this->areCompatibleProductNames($fromText->productName, $fromShop->productName)) {
|
||||
return new CommerceReferenceContext(
|
||||
productName: $fromShop->productName,
|
||||
productNumber: $fromShop->productNumber,
|
||||
manufacturer: $fromShop->manufacturer ?? $fromText->manufacturer,
|
||||
url: $fromShop->url,
|
||||
sourceType: 'shop',
|
||||
confidence: 1.0,
|
||||
resolvedFromPrompt: $fromText->resolvedFromPrompt ?? $fromShop->resolvedFromPrompt,
|
||||
resolvedAt: (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
|
||||
focusTerms: $this->mergeFocusTerms(
|
||||
$fromText->focusTerms,
|
||||
$fromShop->focusTerms
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ($fromText !== null) {
|
||||
return $fromText;
|
||||
}
|
||||
|
||||
return $fromShop;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $shopResults
|
||||
*/
|
||||
private function resolveFromShopResults(string $prompt, array $shopResults): ?CommerceReferenceContext
|
||||
{
|
||||
$top = $shopResults[0] ?? null;
|
||||
|
||||
if (!is_array($top)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = $this->extractString($top, 'name');
|
||||
$productNumber = $this->extractString($top, 'productNumber');
|
||||
$manufacturer = $this->extractString($top, 'manufacturer');
|
||||
$url = $this->extractString($top, 'url');
|
||||
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CommerceReferenceContext(
|
||||
productName: $name,
|
||||
productNumber: $productNumber !== '' ? $productNumber : null,
|
||||
manufacturer: $manufacturer !== '' ? $manufacturer : null,
|
||||
url: $url !== '' ? $url : null,
|
||||
sourceType: 'shop',
|
||||
confidence: 1.0,
|
||||
resolvedFromPrompt: trim($prompt) !== '' ? trim($prompt) : null,
|
||||
resolvedAt: (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
|
||||
focusTerms: $this->extractFocusTerms($prompt . "\n" . $name),
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveFromText(string $prompt, string $answerText): ?CommerceReferenceContext
|
||||
{
|
||||
$text = trim($prompt . "\n" . $answerText);
|
||||
|
||||
if ($text === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$patterns = [
|
||||
'/\b(Testomat\s+2000\s+THCL)\b/ui',
|
||||
'/\b(Testomat\s+808)\b/ui',
|
||||
'/\b(Testomat\s+EVO\s+TH)\b/ui',
|
||||
'/\b(Testomat\s+EVO\s+CALC)\b/ui',
|
||||
'/\b(Testomat\s+ECO\s+PLUS)\b/ui',
|
||||
'/\b(Testomat\s+ECO\s+C)\b/ui',
|
||||
'/\b(Testomat\s+ECO)\b/ui',
|
||||
'/\b(Testomat\s+LAB\s+CL)\b/ui',
|
||||
'/\b(Testomat\s+LAB\s+MONO)\b/ui',
|
||||
'/\b(Testomat\s+2000)\b/ui',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (!preg_match($pattern, $text, $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$productName = trim((string) ($matches[1] ?? ''));
|
||||
|
||||
if ($productName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
return new CommerceReferenceContext(
|
||||
productName: $productName,
|
||||
productNumber: null,
|
||||
manufacturer: null,
|
||||
url: null,
|
||||
sourceType: 'conversation',
|
||||
confidence: 0.8,
|
||||
resolvedFromPrompt: trim($prompt) !== '' ? trim($prompt) : null,
|
||||
resolvedAt: (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
|
||||
focusTerms: $this->extractFocusTerms($text),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function extractFocusTerms(string $text): array
|
||||
{
|
||||
$normalized = $this->normalizeText($text);
|
||||
|
||||
if ($normalized === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$patterns = [
|
||||
'indikator' => '/\bindikator(?:en)?\b/u',
|
||||
'indikatoren' => '/\bindikator(?:en)?\b/u',
|
||||
'reagenz' => '/\breagenz(?:ien)?\b/u',
|
||||
'reagenzien' => '/\breagenz(?:ien)?\b/u',
|
||||
'zubehör' => '/\bzubeh[oö]r\b/u',
|
||||
'ersatzteil' => '/\bersatzteile?\b/u',
|
||||
'ersatzteile' => '/\bersatzteile?\b/u',
|
||||
'service-set' => '/\bservice(?:\s|-)?set\b/u',
|
||||
'filter' => '/\bfilter\b/u',
|
||||
'pumpenkopf' => '/\bpumpenkopf\b/u',
|
||||
'motorblock' => '/\bmotorblock\b/u',
|
||||
'mehrwertpaket' => '/\bmehrwertpaket\b/u',
|
||||
'neotecmaster' => '/\bneotecmaster\b/u',
|
||||
];
|
||||
|
||||
$terms = [];
|
||||
|
||||
foreach ($patterns as $canonical => $pattern) {
|
||||
if (preg_match($pattern, $normalized) === 1) {
|
||||
$terms[] = $canonical;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($terms));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $left
|
||||
* @param string[] $right
|
||||
* @return string[]
|
||||
*/
|
||||
private function mergeFocusTerms(array $left, array $right): array
|
||||
{
|
||||
$merged = [];
|
||||
|
||||
foreach ([$left, $right] as $list) {
|
||||
foreach ($list as $item) {
|
||||
if (!is_string($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = trim($item);
|
||||
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$merged[$item] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($merged);
|
||||
}
|
||||
|
||||
private function areCompatibleProductNames(string $left, string $right): bool
|
||||
{
|
||||
$left = $this->normalizeName($left);
|
||||
$right = $this->normalizeName($right);
|
||||
|
||||
if ($left === '' || $right === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($left === $right) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_contains($left, $right) || str_contains($right, $left);
|
||||
}
|
||||
|
||||
private function normalizeName(string $value): string
|
||||
{
|
||||
$value = mb_strtolower(trim($value), 'UTF-8');
|
||||
$value = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $value) ?? $value;
|
||||
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
private function normalizeText(string $value): string
|
||||
{
|
||||
$value = mb_strtolower(trim($value), 'UTF-8');
|
||||
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function extractString(array $row, string $key): string
|
||||
{
|
||||
$value = $row[$key] ?? null;
|
||||
|
||||
if (!is_string($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
99
src/Commerce/CommerceReferenceStore.php
Normal file
99
src/Commerce/CommerceReferenceStore.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commerce;
|
||||
|
||||
use App\Commerce\Dto\CommerceReferenceContext;
|
||||
|
||||
final readonly class CommerceReferenceStore
|
||||
{
|
||||
private string $directory;
|
||||
|
||||
public function __construct(string $projectDir)
|
||||
{
|
||||
$this->directory = rtrim($projectDir, '/') . '/var/agent-commerce-context';
|
||||
|
||||
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Failed to create commerce reference directory: %s',
|
||||
$this->directory
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
public function load(string $userId): ?CommerceReferenceContext
|
||||
{
|
||||
$path = $this->getPath($userId);
|
||||
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
|
||||
if ($content === false || trim($content) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($content, true);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CommerceReferenceContext::fromArray($decoded);
|
||||
}
|
||||
|
||||
public function save(string $userId, CommerceReferenceContext $context): void
|
||||
{
|
||||
$path = $this->getPath($userId);
|
||||
$tmpPath = $path . '.tmp';
|
||||
|
||||
$json = json_encode(
|
||||
$context->toArray(),
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE
|
||||
);
|
||||
|
||||
if (!is_string($json)) {
|
||||
throw new \RuntimeException('Failed to encode commerce reference context.');
|
||||
}
|
||||
|
||||
if (file_put_contents($tmpPath, $json, LOCK_EX) === false) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Failed to write commerce reference context: %s',
|
||||
$tmpPath
|
||||
));
|
||||
}
|
||||
|
||||
if (!rename($tmpPath, $path)) {
|
||||
@unlink($tmpPath);
|
||||
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Failed to move commerce reference context into place: %s',
|
||||
$path
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
public function clear(string $userId): void
|
||||
{
|
||||
$path = $this->getPath($userId);
|
||||
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function getPath(string $userId): string
|
||||
{
|
||||
$safeUserId = preg_replace('/[^a-zA-Z0-9_-]/', '_', trim($userId));
|
||||
$safeUserId = is_string($safeUserId) ? trim($safeUserId, '_') : '';
|
||||
|
||||
if ($safeUserId === '') {
|
||||
throw new \InvalidArgumentException('User id must not be empty.');
|
||||
}
|
||||
|
||||
return $this->directory . '/' . $safeUserId . '.json';
|
||||
}
|
||||
}
|
||||
149
src/Commerce/Dto/CommerceReferenceContext.php
Normal file
149
src/Commerce/Dto/CommerceReferenceContext.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commerce\Dto;
|
||||
|
||||
final readonly class CommerceReferenceContext
|
||||
{
|
||||
/**
|
||||
* @param string[] $focusTerms
|
||||
*/
|
||||
public function __construct(
|
||||
public string $productName,
|
||||
public ?string $productNumber = null,
|
||||
public ?string $manufacturer = null,
|
||||
public ?string $url = null,
|
||||
public string $sourceType = 'conversation',
|
||||
public float $confidence = 0.0,
|
||||
public ?string $resolvedFromPrompt = null,
|
||||
public ?string $resolvedAt = null,
|
||||
public array $focusTerms = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function fromArray(array $payload): ?self
|
||||
{
|
||||
$productName = self::normalizeNullableString($payload['productName'] ?? null);
|
||||
|
||||
if ($productName === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self(
|
||||
productName: $productName,
|
||||
productNumber: self::normalizeNullableString($payload['productNumber'] ?? null),
|
||||
manufacturer: self::normalizeNullableString($payload['manufacturer'] ?? null),
|
||||
url: self::normalizeNullableString($payload['url'] ?? null),
|
||||
sourceType: self::normalizeNullableString($payload['sourceType'] ?? null) ?? 'conversation',
|
||||
confidence: isset($payload['confidence']) && is_numeric($payload['confidence']) ? (float) $payload['confidence'] : 0.0,
|
||||
resolvedFromPrompt: self::normalizeNullableString($payload['resolvedFromPrompt'] ?? null),
|
||||
resolvedAt: self::normalizeNullableString($payload['resolvedAt'] ?? null),
|
||||
focusTerms: self::normalizeStringList($payload['focusTerms'] ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'productName' => $this->productName,
|
||||
'productNumber' => $this->productNumber,
|
||||
'manufacturer' => $this->manufacturer,
|
||||
'url' => $this->url,
|
||||
'sourceType' => $this->sourceType,
|
||||
'confidence' => $this->confidence,
|
||||
'resolvedFromPrompt' => $this->resolvedFromPrompt,
|
||||
'resolvedAt' => $this->resolvedAt,
|
||||
'focusTerms' => $this->focusTerms,
|
||||
];
|
||||
}
|
||||
|
||||
public function hasStrongIdentity(): bool
|
||||
{
|
||||
return $this->productNumber !== null || $this->confidence >= 0.8;
|
||||
}
|
||||
|
||||
public function buildReferenceSearchText(): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if ($this->productName !== '') {
|
||||
$parts[] = $this->productName;
|
||||
}
|
||||
|
||||
if (
|
||||
$this->productNumber !== null
|
||||
&& $this->productNumber !== ''
|
||||
&& stripos($this->productName, $this->productNumber) === false
|
||||
) {
|
||||
$parts[] = $this->productNumber;
|
||||
}
|
||||
|
||||
foreach ($this->focusTerms as $focusTerm) {
|
||||
if ($focusTerm === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$alreadyIncluded = false;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (stripos($part, $focusTerm) !== false) {
|
||||
$alreadyIncluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$alreadyIncluded) {
|
||||
$parts[] = $focusTerm;
|
||||
}
|
||||
}
|
||||
|
||||
return trim(implode(' ', $parts));
|
||||
}
|
||||
|
||||
private static function normalizeNullableString(mixed $value): ?string
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return string[]
|
||||
*/
|
||||
private static function normalizeStringList(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($value as $item) {
|
||||
if (!is_string($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = trim($item);
|
||||
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = $item;
|
||||
}
|
||||
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user