diff --git a/src/Agent/AgentRunner.php b/src/Agent/AgentRunner.php index 1a6868f..0196504 100644 --- a/src/Agent/AgentRunner.php +++ b/src/Agent/AgentRunner.php @@ -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, diff --git a/src/Commerce/CommerceQueryParser.php b/src/Commerce/CommerceQueryParser.php index 1e5f1d7..ead10d2 100644 --- a/src/Commerce/CommerceQueryParser.php +++ b/src/Commerce/CommerceQueryParser.php @@ -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; diff --git a/src/Config/CommerceQueryParserConfig.php b/src/Config/CommerceQueryParserConfig.php index 4dbe405..438eb17 100644 --- a/src/Config/CommerceQueryParserConfig.php +++ b/src/Config/CommerceQueryParserConfig.php @@ -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'; diff --git a/src/Controller/AskSseController.php b/src/Controller/AskSseController.php index fc7be8c..0b57d89 100644 --- a/src/Controller/AskSseController.php +++ b/src/Controller/AskSseController.php @@ -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 {