optimize cleanup search query shop api extends

This commit is contained in:
team2
2026-04-25 22:05:35 +02:00
parent 4823752b3e
commit 6cf8aac872
4 changed files with 327 additions and 5 deletions

View File

@@ -61,6 +61,7 @@ final readonly class AgentRunner
$usedShopRepair = false;
$shopRepairQueries = [];
$primaryShopSearchHadSystemFailure = false;
$historyNotices = [];
$this->agentLogger->info('Agent run started', [
'userId' => $userId,
@@ -170,10 +171,15 @@ final readonly class AgentRunner
'failureReason' => $primaryShopSearchFailureReason,
]);
$shopUnavailableMessage = $this->buildShopUnavailableMessage($primaryShopSearchFailureReason);
yield $this->systemMsg(
$this->buildShopUnavailableMessage($primaryShopSearchFailureReason),
$shopUnavailableMessage,
'err'
);
$historyNotices[] = $this->buildHistoryNotice(
'Shopdaten konnten nicht geladen werden',
$primaryShopSearchFailureReason
);
$repairPayload = [
'results' => $primaryShopResults,
@@ -271,11 +277,13 @@ final readonly class AgentRunner
yield $this->systemMsg($finalPrompt, 'debug');
}
if ($fullOutput !== '') {
$historyResponse = $this->buildHistoryResponse($fullOutput, $historyNotices);
if ($historyResponse !== '') {
$this->contextService->appendHistory(
$userId,
$prompt,
$fullOutput
$historyResponse
);
}
@@ -307,7 +315,17 @@ final readonly class AgentRunner
'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(
string $query,
string $commerceIntent,

View File

@@ -239,10 +239,148 @@ final readonly class CommerceQueryParser
);
$tokens = $this->normalizeSearchTokens($tokens);
$tokens = $this->compactShopSearchTokens($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
{
return preg_match($this->config->getHistoryContextValuePattern(), $prompt) === 1;

View File

@@ -275,6 +275,86 @@ final class CommerceQueryParserConfig
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
{
return '/\b' . preg_quote($token, '/') . '\b/u';

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Agent\AgentRunner;
use App\Context\ContextService;
use App\Http\ClientIdResolver;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -19,6 +20,7 @@ final readonly class AskSseController
public function __construct(
private AgentRunner $agentRunner,
private ClientIdResolver $clientIdResolver,
private ContextService $contextService,
private string $projectDir,
) {
}
@@ -166,15 +168,39 @@ final readonly class AskSseController
$this->sendData($chunk);
}
} catch (\Throwable $e) {
$message = 'Stream abgebrochen: ' . $this->formatThrowableForClient($e);
$this->sendEvent(
'error',
'❌ Stream abgebrochen: ' . $this->formatThrowableForClient($e)
'❌ ' . $message
);
if ($prompt !== '' && $clientId !== '') {
$this->appendHistoryFailure($clientId, $prompt, $message);
}
}
$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
{
register_shutdown_function(function (): void {