optimize cleanup search query shop api extends
This commit is contained in:
@@ -61,6 +61,7 @@ final readonly class AgentRunner
|
|||||||
$usedShopRepair = false;
|
$usedShopRepair = false;
|
||||||
$shopRepairQueries = [];
|
$shopRepairQueries = [];
|
||||||
$primaryShopSearchHadSystemFailure = false;
|
$primaryShopSearchHadSystemFailure = false;
|
||||||
|
$historyNotices = [];
|
||||||
|
|
||||||
$this->agentLogger->info('Agent run started', [
|
$this->agentLogger->info('Agent run started', [
|
||||||
'userId' => $userId,
|
'userId' => $userId,
|
||||||
@@ -170,10 +171,15 @@ final readonly class AgentRunner
|
|||||||
'failureReason' => $primaryShopSearchFailureReason,
|
'failureReason' => $primaryShopSearchFailureReason,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$shopUnavailableMessage = $this->buildShopUnavailableMessage($primaryShopSearchFailureReason);
|
||||||
yield $this->systemMsg(
|
yield $this->systemMsg(
|
||||||
$this->buildShopUnavailableMessage($primaryShopSearchFailureReason),
|
$shopUnavailableMessage,
|
||||||
'err'
|
'err'
|
||||||
);
|
);
|
||||||
|
$historyNotices[] = $this->buildHistoryNotice(
|
||||||
|
'Shopdaten konnten nicht geladen werden',
|
||||||
|
$primaryShopSearchFailureReason
|
||||||
|
);
|
||||||
|
|
||||||
$repairPayload = [
|
$repairPayload = [
|
||||||
'results' => $primaryShopResults,
|
'results' => $primaryShopResults,
|
||||||
@@ -271,11 +277,13 @@ final readonly class AgentRunner
|
|||||||
yield $this->systemMsg($finalPrompt, 'debug');
|
yield $this->systemMsg($finalPrompt, 'debug');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($fullOutput !== '') {
|
$historyResponse = $this->buildHistoryResponse($fullOutput, $historyNotices);
|
||||||
|
|
||||||
|
if ($historyResponse !== '') {
|
||||||
$this->contextService->appendHistory(
|
$this->contextService->appendHistory(
|
||||||
$userId,
|
$userId,
|
||||||
$prompt,
|
$prompt,
|
||||||
$fullOutput
|
$historyResponse
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +315,17 @@ final readonly class AgentRunner
|
|||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
yield $this->systemMsg($this->buildUserErrorMessage($e), 'err');
|
$userErrorMessage = $this->buildUserErrorMessage($e);
|
||||||
|
yield $this->systemMsg($userErrorMessage, 'err');
|
||||||
|
|
||||||
|
$historyResponse = $this->buildHistoryResponse('', array_merge(
|
||||||
|
$historyNotices,
|
||||||
|
[$this->buildHistoryNotice('Antwort konnte nicht abgeschlossen werden', $e->getMessage())]
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($historyResponse !== '') {
|
||||||
|
$this->contextService->appendHistory($userId, $prompt, $historyResponse);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,6 +824,66 @@ final readonly class AgentRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $notices
|
||||||
|
*/
|
||||||
|
private function buildHistoryResponse(string $fullOutput, array $notices): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
foreach ($notices as $notice) {
|
||||||
|
$notice = trim($notice);
|
||||||
|
|
||||||
|
if ($notice !== '') {
|
||||||
|
$parts[] = $notice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullOutput = trim($fullOutput);
|
||||||
|
|
||||||
|
if ($fullOutput !== '') {
|
||||||
|
$parts[] = $fullOutput;
|
||||||
|
} else {
|
||||||
|
$noLlmMessage = $this->plainTextFromHtml($this->agentRunnerConfig->getNoLlmDataReceivedMessage());
|
||||||
|
|
||||||
|
if ($noLlmMessage === '') {
|
||||||
|
$noLlmMessage = 'Es wurden keine Daten vom LLM empfangen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = 'Systemhinweis: ' . $noLlmMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim(implode("\n\n", $parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildHistoryNotice(string $title, ?string $detail): string
|
||||||
|
{
|
||||||
|
$title = $this->normalizeOneLine($this->plainTextFromHtml($title));
|
||||||
|
$detail = $this->normalizeOneLine($this->plainTextFromHtml((string) $detail));
|
||||||
|
|
||||||
|
if ($title === '') {
|
||||||
|
$title = 'Systemhinweis';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($detail === '') {
|
||||||
|
return 'Systemhinweis: ' . $title . '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($detail, 'UTF-8') > 500) {
|
||||||
|
$detail = rtrim(mb_substr($detail, 0, 497, 'UTF-8')) . '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Systemhinweis: ' . $title . '. Ursache: ' . $detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function plainTextFromHtml(string $value): string
|
||||||
|
{
|
||||||
|
$value = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||||
|
|
||||||
|
return trim($value);
|
||||||
|
}
|
||||||
|
|
||||||
private function buildShopSearchMetaMessage(
|
private function buildShopSearchMetaMessage(
|
||||||
string $query,
|
string $query,
|
||||||
string $commerceIntent,
|
string $commerceIntent,
|
||||||
|
|||||||
@@ -239,10 +239,148 @@ final readonly class CommerceQueryParser
|
|||||||
);
|
);
|
||||||
|
|
||||||
$tokens = $this->normalizeSearchTokens($tokens);
|
$tokens = $this->normalizeSearchTokens($tokens);
|
||||||
|
$tokens = $this->compactShopSearchTokens($tokens);
|
||||||
|
|
||||||
return trim(implode(' ', $tokens));
|
return trim(implode(' ', $tokens));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the Store API query narrow without relying on endless spelling-specific stop words.
|
||||||
|
*
|
||||||
|
* Direct product queries often contain user instructions such as "show all as a list".
|
||||||
|
* Shopware search performs best when the query only contains product-defining tokens:
|
||||||
|
* model numbers, the immediately related model name, brands, and semantic commerce terms.
|
||||||
|
*
|
||||||
|
* @param string[] $tokens
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function compactShopSearchTokens(array $tokens): array
|
||||||
|
{
|
||||||
|
$tokens = array_values(array_filter(
|
||||||
|
$tokens,
|
||||||
|
fn(string $token): bool => !$this->isQueryNoiseToken($token)
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($tokens === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$keep = [];
|
||||||
|
|
||||||
|
foreach ($tokens as $index => $token) {
|
||||||
|
if ($this->isModelNumberToken($token)) {
|
||||||
|
$keep[$index] = true;
|
||||||
|
|
||||||
|
for ($offset = 1; $offset <= $this->config->getModelContextTokenWindow(); $offset++) {
|
||||||
|
$previousIndex = $index - $offset;
|
||||||
|
|
||||||
|
if (!isset($tokens[$previousIndex]) || !$this->isLikelyModelContextToken($tokens[$previousIndex])) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keep[$previousIndex] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextIndex = $index + 1;
|
||||||
|
if (isset($tokens[$nextIndex]) && $this->isModelSuffixToken($tokens[$nextIndex])) {
|
||||||
|
$keep[$nextIndex] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isSemanticShopToken($token) || $this->isKnownBrandToken($token)) {
|
||||||
|
$keep[$index] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($keep === []) {
|
||||||
|
return $this->limitShopSearchTokens($tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($keep);
|
||||||
|
|
||||||
|
$compacted = [];
|
||||||
|
foreach (array_keys($keep) as $index) {
|
||||||
|
$compacted[] = $tokens[$index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->limitShopSearchTokens(array_values(array_unique($compacted)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isQueryNoiseToken(string $token): bool
|
||||||
|
{
|
||||||
|
$token = trim(mb_strtolower($token, 'UTF-8'));
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match($this->config->getContainsDigitPattern(), $token) === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($token) <= $this->config->getMinMeaningfulAlphaTokenLength()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isSearchControlToken($token)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match($this->config->getInstructionOrPresentationTokenPattern(), $token) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isModelNumberToken(string $token): bool
|
||||||
|
{
|
||||||
|
return preg_match($this->config->getModelNumberTokenPattern(), $token) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isLikelyModelContextToken(string $token): bool
|
||||||
|
{
|
||||||
|
if ($this->isQueryNoiseToken($token)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isSemanticShopToken($token)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match($this->config->getModelContextTokenPattern(), $token) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isModelSuffixToken(string $token): bool
|
||||||
|
{
|
||||||
|
if ($this->isQueryNoiseToken($token)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match($this->config->getModelSuffixTokenPattern(), $token) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSemanticShopToken(string $token): bool
|
||||||
|
{
|
||||||
|
return in_array($token, $this->config->getSemanticShopSearchTokens(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isKnownBrandToken(string $token): bool
|
||||||
|
{
|
||||||
|
return in_array($token, $this->config->getKnownBrands(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $tokens
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function limitShopSearchTokens(array $tokens): array
|
||||||
|
{
|
||||||
|
$limit = $this->config->getMaxShopSearchTokens();
|
||||||
|
|
||||||
|
if ($limit <= 0 || count($tokens) <= $limit) {
|
||||||
|
return $tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice($tokens, 0, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
private function shouldUseHistoryContext(string $prompt): bool
|
private function shouldUseHistoryContext(string $prompt): bool
|
||||||
{
|
{
|
||||||
return preg_match($this->config->getHistoryContextValuePattern(), $prompt) === 1;
|
return preg_match($this->config->getHistoryContextValuePattern(), $prompt) === 1;
|
||||||
|
|||||||
@@ -275,6 +275,86 @@ final class CommerceQueryParserConfig
|
|||||||
return '/\b(?:indikator|indicator|reagenz|reagent|kit|set)\s+\d{1,5}[a-z0-9\-]*\b/u';
|
return '/\b(?:indikator|indicator|reagenz|reagent|kit|set)\s+\d{1,5}[a-z0-9\-]*\b/u';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getContainsDigitPattern(): string
|
||||||
|
{
|
||||||
|
return '/\d/u';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getModelNumberTokenPattern(): string
|
||||||
|
{
|
||||||
|
return '/^(?:\d{2,5}[a-z0-9\-]*|[a-z]{1,6}\d{1,5}[a-z0-9\-]*)$/u';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getModelContextTokenPattern(): string
|
||||||
|
{
|
||||||
|
return '/^[\p{L}][\p{L}0-9®\-]{2,}$/u';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getModelSuffixTokenPattern(): string
|
||||||
|
{
|
||||||
|
return '/^[a-z]{1,4}\d{0,3}$/u';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getModelContextTokenWindow(): int
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMinMeaningfulAlphaTokenLength(): int
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMaxShopSearchTokens(): int
|
||||||
|
{
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInstructionOrPresentationTokenPattern(): string
|
||||||
|
{
|
||||||
|
return '/^(?:zeig(?:e)?|such(?:e)?|find(?:e)?|gib|gebe|nenn(?:e)?|liefer(?:e)?|erstelle?|mach(?:e)?|brauch(?:e)?|will|möchte|moechte|hätte|haette|kannst|bitte|mal|alle|alles|komplett|vollständig|vollstaendig|gesamt|ganze|ganzen|liste|listung|auflistung|tabelle|tabellarisch|übersicht|uebersicht|anzeigen?|ausgeben?|darstellen?|antwort(?:e)?|erklär(?:e)?|erklaer(?:e)?|info|infos|informationen|dazu|hierzu|damit|davon|an|als|mit|ohne|inkl|inklusive)$/u';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product/category tokens that are useful for Store API search even when they are not next to a model number.
|
||||||
|
* This is intentionally a semantic allowlist, not a spelling-error blocklist.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function getSemanticShopSearchTokens(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'indikator',
|
||||||
|
'indicator',
|
||||||
|
'reagenz',
|
||||||
|
'reagent',
|
||||||
|
'zubehör',
|
||||||
|
'zubehor',
|
||||||
|
'ersatzteil',
|
||||||
|
'verbrauchsmaterial',
|
||||||
|
'kit',
|
||||||
|
'set',
|
||||||
|
'filter',
|
||||||
|
'pumpe',
|
||||||
|
'pumpenkopf',
|
||||||
|
'motorblock',
|
||||||
|
'lösung',
|
||||||
|
'loesung',
|
||||||
|
'solution',
|
||||||
|
'teststreifen',
|
||||||
|
'gerät',
|
||||||
|
'geraet',
|
||||||
|
'messgerät',
|
||||||
|
'messgeraet',
|
||||||
|
'analysegerät',
|
||||||
|
'analysegeraet',
|
||||||
|
'analysator',
|
||||||
|
'monitor',
|
||||||
|
'controller',
|
||||||
|
'system',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function buildExactTokenRemovalPattern(string $token): string
|
public function buildExactTokenRemovalPattern(string $token): string
|
||||||
{
|
{
|
||||||
return '/\b' . preg_quote($token, '/') . '\b/u';
|
return '/\b' . preg_quote($token, '/') . '\b/u';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Agent\AgentRunner;
|
use App\Agent\AgentRunner;
|
||||||
|
use App\Context\ContextService;
|
||||||
use App\Http\ClientIdResolver;
|
use App\Http\ClientIdResolver;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -19,6 +20,7 @@ final readonly class AskSseController
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private AgentRunner $agentRunner,
|
private AgentRunner $agentRunner,
|
||||||
private ClientIdResolver $clientIdResolver,
|
private ClientIdResolver $clientIdResolver,
|
||||||
|
private ContextService $contextService,
|
||||||
private string $projectDir,
|
private string $projectDir,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -166,15 +168,39 @@ final readonly class AskSseController
|
|||||||
$this->sendData($chunk);
|
$this->sendData($chunk);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
$message = 'Stream abgebrochen: ' . $this->formatThrowableForClient($e);
|
||||||
$this->sendEvent(
|
$this->sendEvent(
|
||||||
'error',
|
'error',
|
||||||
'❌ Stream abgebrochen: ' . $this->formatThrowableForClient($e)
|
'❌ ' . $message
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($prompt !== '' && $clientId !== '') {
|
||||||
|
$this->appendHistoryFailure($clientId, $prompt, $message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->sendEvent('done', '[DONE]');
|
$this->sendEvent('done', '[DONE]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function appendHistoryFailure(string $clientId, string $prompt, string $message): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$message = trim(preg_replace('/\s+/u', ' ', $message) ?? $message);
|
||||||
|
|
||||||
|
if ($message === '') {
|
||||||
|
$message = 'Unbekannter Streamfehler.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->contextService->appendHistory(
|
||||||
|
$clientId,
|
||||||
|
$prompt,
|
||||||
|
'Systemhinweis: Antwort konnte nicht abgeschlossen werden. Ursache: ' . $message
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// History persistence must never break the SSE error response.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function registerStreamShutdownErrorHandler(): void
|
private function registerStreamShutdownErrorHandler(): void
|
||||||
{
|
{
|
||||||
register_shutdown_function(function (): void {
|
register_shutdown_function(function (): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user