harden information getter services and optimize user msg

This commit is contained in:
team2
2026-04-27 22:21:21 +02:00
parent 316a5b5cc2
commit 79adf8f1df
7 changed files with 506 additions and 25 deletions

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Agent;
use App\Commerce\Dto\ShopProductResult;
use App\Commerce\SearchRepairService;
use App\Commerce\ShopSearchService;
use App\Config\AgentRunnerConfig;
@@ -62,6 +63,7 @@ final readonly class AgentRunner
$attemptedShopRepair = false;
$usedShopRepair = false;
$shopRepairQueries = [];
$shopSearchAttempted = false;
$primaryShopSearchHadSystemFailure = false;
$historyNotices = [];
@@ -141,11 +143,18 @@ final readonly class AgentRunner
'hasRequestContextHint' => trim($requestContextHint) !== '',
]);
$noConcreteShopQueryMessage = $this->agentRunnerConfig->getNoConcreteShopQueryMessage();
yield $this->systemMsg(
$this->agentRunnerConfig->getNoConcreteShopQueryMessage(),
$noConcreteShopQueryMessage,
'info'
);
$this->contextService->appendHistory(
$userId,
$prompt,
$this->plainTextFromHtml($noConcreteShopQueryMessage)
);
return;
} else {
@@ -185,6 +194,7 @@ final readonly class AgentRunner
'think'
);
$shopSearchAttempted = true;
$primaryShopResults = $this->searchShop(
$shopSearchQuery,
$commerceIntent,
@@ -276,7 +286,9 @@ final readonly class AgentRunner
knowledgeChunks: $knowledgeChunks,
shopResults: $shopResults,
fullContext: $forceFullContext,
swagFullOutPut: $optimizedShopQuery
swagFullOutPut: $optimizedShopQuery,
commerceSearchAttempted: $shopSearchAttempted,
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure
);
if ($this->debug && $this->logPrompt) {
@@ -292,6 +304,7 @@ final readonly class AgentRunner
'attemptedShopRepair' => $attemptedShopRepair,
'usedShopRepair' => $usedShopRepair,
'shopRepairQueries' => $shopRepairQueries,
'shopSearchAttempted' => $shopSearchAttempted,
]);
}
@@ -312,7 +325,18 @@ final readonly class AgentRunner
);
}
$fullOutput = yield from $this->streamFinalAnswer($finalPrompt);
$noLlmFallbackAnswer = $this->buildNoLlmFallbackAnswer(
prompt: $prompt,
urlContent: $urlContent,
knowledgeChunks: $knowledgeChunks,
shopResults: $shopResults,
commerceIntent: $commerceIntent,
shopSearchAttempted: $shopSearchAttempted,
shopSearchHadSystemFailure: $primaryShopSearchHadSystemFailure,
shopSearchFailureReason: $primaryShopSearchFailureReason ?? null
);
$fullOutput = yield from $this->streamFinalAnswer($finalPrompt, $noLlmFallbackAnswer);
if ($sources !== []) {
yield $this->emitSources(
@@ -345,6 +369,7 @@ final readonly class AgentRunner
'attemptedShopRepair' => $attemptedShopRepair,
'usedShopRepair' => $usedShopRepair,
'shopRepairQueries' => $shopRepairQueries,
'shopSearchAttempted' => $shopSearchAttempted,
'primaryShopSearchHadSystemFailure' => $primaryShopSearchHadSystemFailure,
'primaryShopSearchFailureReason' => $primaryShopSearchFailureReason ?? null,
'knowledgeChunkCount' => count($knowledgeChunks),
@@ -1178,7 +1203,7 @@ final readonly class AgentRunner
/**
* @return Generator<int, string, mixed, string>
*/
private function streamFinalAnswer(string $finalPrompt): Generator
private function streamFinalAnswer(string $finalPrompt, string $noLlmFallbackAnswer = ''): Generator
{
$fullOutput = '';
$thinkingNoticeShown = false;
@@ -1189,40 +1214,218 @@ final readonly class AgentRunner
yield $this->systemMsg($this->agentRunnerConfig->getThinkingWhileStreamingMessage(), 'think');
$thinkingNoticeShown = true;
foreach ($this->ollamaClient->stream($finalPrompt) as $token) {
if (!is_string($token)) {
continue;
}
$cleanToken = $this->thinkSuppressor->filter($token);
if ($cleanToken === '') {
if (!$thinkingNoticeShown) {
yield $this->systemMsg($this->agentRunnerConfig->getThinkingWhileStreamingMessage(), 'think');
$thinkingNoticeShown = true;
try {
foreach ($this->ollamaClient->stream($finalPrompt) as $token) {
if (!is_string($token)) {
continue;
}
continue;
$cleanToken = $this->thinkSuppressor->filter($token);
if ($cleanToken === '') {
if (!$thinkingNoticeShown) {
yield $this->systemMsg($this->agentRunnerConfig->getThinkingWhileStreamingMessage(), 'think');
$thinkingNoticeShown = true;
}
continue;
}
$fullOutput .= $cleanToken;
$chunk = $chunker->push($cleanToken);
if ($chunk !== null) {
yield $this->systemMsg($chunk, 'answer');
}
}
} catch (Throwable $e) {
$noLlmFallbackAnswer = trim($noLlmFallbackAnswer);
if ($noLlmFallbackAnswer === '' || $fullOutput !== '') {
throw $e;
}
$fullOutput .= $cleanToken;
$this->agentLogger->warning('LLM stream failed before answer tokens, using deterministic no-LLM fallback answer', [
'exception' => $e,
]);
$chunk = $chunker->push($cleanToken);
if ($chunk !== null) {
yield $this->systemMsg($chunk, 'answer');
}
$fullOutput = $noLlmFallbackAnswer;
yield $this->systemMsg($noLlmFallbackAnswer, 'answer');
return $fullOutput;
}
$finalChunk = $chunker->flush();
if ($finalChunk !== null) {
yield $this->systemMsg($finalChunk, 'answer');
} elseif ($fullOutput === '') {
yield $this->systemMsg($this->agentRunnerConfig->getNoLlmDataReceivedMessage(), 'err');
$noLlmFallbackAnswer = trim($noLlmFallbackAnswer);
if ($noLlmFallbackAnswer !== '') {
$fullOutput = $noLlmFallbackAnswer;
yield $this->systemMsg($noLlmFallbackAnswer, 'answer');
} else {
yield $this->systemMsg($this->agentRunnerConfig->getNoLlmDataReceivedMessage(), 'err');
}
}
return $fullOutput;
}
/**
* Build a deterministic safety answer for environments where the LLM returns no tokens.
*
* This intentionally does not infer technical suitability from weak evidence. It only reports
* reliable system state, shop hit metadata and the next safe action.
*
* @param string[] $knowledgeChunks
* @param ShopProductResult[] $shopResults
*/
private function buildNoLlmFallbackAnswer(
string $prompt,
string $urlContent,
array $knowledgeChunks,
array $shopResults,
string $commerceIntent,
bool $shopSearchAttempted,
bool $shopSearchHadSystemFailure,
?string $shopSearchFailureReason
): string {
$hasKnowledge = $knowledgeChunks !== [] || trim($urlContent) !== '';
$hasShopResults = $shopResults !== [];
$isCommerceIntent = $this->isCommerceIntent($commerceIntent);
if ($hasShopResults) {
return $this->buildNoLlmShopFallbackAnswer(
hasKnowledge: $hasKnowledge,
shopResults: $shopResults
);
}
if ($shopSearchHadSystemFailure) {
return $this->buildNoLlmShopUnavailableAnswer(
hasKnowledge: $hasKnowledge,
reason: $shopSearchFailureReason
);
}
if ($isCommerceIntent && $shopSearchAttempted) {
return $this->buildNoLlmNoShopResultsAnswer($hasKnowledge);
}
if ($hasKnowledge) {
return $this->agentRunnerConfig->getNoLlmFallbackKnowledgeOnlyMessage();
}
return $this->agentRunnerConfig->getNoLlmFallbackNoDataMessage();
}
/**
* @param ShopProductResult[] $shopResults
*/
private function buildNoLlmShopFallbackAnswer(bool $hasKnowledge, array $shopResults): string
{
$intro = $hasKnowledge
? $this->agentRunnerConfig->getNoLlmFallbackShopWithKnowledgeMessage()
: $this->agentRunnerConfig->getNoLlmFallbackShopOnlyMessage();
$lines = [$intro, ''];
foreach ($this->buildNoLlmShopProductLines($shopResults) as $line) {
$lines[] = $line;
}
$escalation = trim($this->agentRunnerConfig->getNoLlmFallbackEscalationMessage());
if ($escalation !== '') {
$lines[] = '';
$lines[] = $escalation;
}
return trim(implode("\n", $lines));
}
private function buildNoLlmShopUnavailableAnswer(bool $hasKnowledge, ?string $reason): string
{
$reason = $this->normalizeOneLine((string) $reason);
$message = $hasKnowledge
? $this->agentRunnerConfig->getNoLlmFallbackShopUnavailableWithKnowledgeMessage()
: $this->agentRunnerConfig->getNoLlmFallbackShopUnavailableNoKnowledgeMessage();
if ($reason !== '') {
$message .= ' Ursache: ' . $reason;
}
return trim($message);
}
private function buildNoLlmNoShopResultsAnswer(bool $hasKnowledge): string
{
return $hasKnowledge
? $this->agentRunnerConfig->getNoLlmFallbackNoShopResultsWithKnowledgeMessage()
: $this->agentRunnerConfig->getNoLlmFallbackNoShopResultsNoKnowledgeMessage();
}
/**
* @param ShopProductResult[] $shopResults
* @return string[]
*/
private function buildNoLlmShopProductLines(array $shopResults): array
{
$maxResults = max(1, $this->agentRunnerConfig->getNoLlmFallbackMaxShopResults());
$lines = [];
$index = 1;
foreach ($shopResults as $product) {
if (!$product instanceof ShopProductResult) {
continue;
}
$lines[] = $this->formatNoLlmShopProductLine($product, $index);
$index++;
if (count($lines) >= $maxResults) {
break;
}
}
if ($lines === []) {
return ['- Es wurden Shop-Treffer übergeben, aber keine lesbaren Produktdaten gefunden.'];
}
return $lines;
}
private function formatNoLlmShopProductLine(ShopProductResult $product, int $index): string
{
$parts = [];
$name = $this->normalizeOneLine($product->name);
$parts[] = $name !== '' ? $name : 'Unbenanntes Shop-Produkt';
if ($product->productNumber !== null && trim($product->productNumber) !== '') {
$parts[] = 'Art.-Nr. ' . $this->normalizeOneLine($product->productNumber);
}
if ($product->manufacturer !== null && trim($product->manufacturer) !== '') {
$parts[] = 'Hersteller: ' . $this->normalizeOneLine($product->manufacturer);
}
if ($product->price !== null && trim($product->price) !== '') {
$parts[] = 'Preis: ' . $this->normalizeOneLine($product->price);
}
if ($product->available !== null) {
$parts[] = 'Verfügbar: ' . ($product->available ? 'ja' : 'nein');
}
if ($product->url !== null && trim($product->url) !== '') {
$parts[] = 'URL: ' . $this->normalizeOneLine($product->url);
}
return sprintf('%d. %s', $index, implode(' | ', $parts));
}
/**
* @param string[] $sources
*/

View File

@@ -40,15 +40,24 @@ final readonly class PromptBuilder
array $knowledgeChunks,
array $shopResults = [],
?bool $fullContext = false,
?string $swagFullOutPut = ''
?string $swagFullOutPut = '',
bool $commerceSearchAttempted = false,
bool $shopSearchHadSystemFailure = false
): string {
$prompt = $this->normalizeBlockText($prompt);
$urlContent = $this->normalizeBlockText($urlContent);
$swagFullOutPut = $this->normalizeNullableBlockText($swagFullOutPut);
$hasShopResults = $shopResults !== [];
$hasKnowledge = $knowledgeChunks !== [] || $urlContent !== '';
$isTechnicalProductQuestion = $this->isLikelyTechnicalProductQuestion($prompt);
$asksForAccessoryOrBundle = $this->asksForAccessoryOrBundle($prompt);
$reliabilityState = $this->resolveReliabilityState(
hasKnowledge: $hasKnowledge,
hasShopResults: $hasShopResults,
commerceSearchAttempted: $commerceSearchAttempted,
shopSearchHadSystemFailure: $shopSearchHadSystemFailure
);
$systemBlock = $this->buildSystemBlock();
$shopBlock = $this->buildShopBlock($shopResults, $swagFullOutPut);
@@ -56,6 +65,13 @@ final readonly class PromptBuilder
hasShopResults: $hasShopResults,
isTechnicalProductQuestion: $isTechnicalProductQuestion
);
$fallbackEscalationBlock = $this->buildFallbackEscalationBlock(
reliabilityState: $reliabilityState,
hasShopResults: $hasShopResults,
commerceSearchAttempted: $commerceSearchAttempted,
shopSearchHadSystemFailure: $shopSearchHadSystemFailure,
isTechnicalProductQuestion: $isTechnicalProductQuestion
);
$responseFormatBlock = $this->buildResponseFormatBlock(
hasShopResults: $hasShopResults,
isTechnicalProductQuestion: $isTechnicalProductQuestion,
@@ -73,6 +89,7 @@ final readonly class PromptBuilder
$systemBlock,
$shopBlock,
$outputPriorityBlock,
$fallbackEscalationBlock,
$responseFormatBlock,
$knowledgeBlock,
$userBlock,
@@ -88,6 +105,7 @@ final readonly class PromptBuilder
$systemBlock,
$shopBlock,
$outputPriorityBlock,
$fallbackEscalationBlock,
$responseFormatBlock,
$knowledgeBlock,
$contextBlock,
@@ -239,6 +257,66 @@ final readonly class PromptBuilder
);
}
private function resolveReliabilityState(
bool $hasKnowledge,
bool $hasShopResults,
bool $commerceSearchAttempted,
bool $shopSearchHadSystemFailure
): string {
if ($shopSearchHadSystemFailure && !$hasKnowledge) {
return 'shopdaten_nicht_verfuegbar';
}
if ($hasKnowledge && !$hasShopResults) {
return 'sicher_beantwortbar';
}
if ($hasKnowledge && $hasShopResults) {
return 'wahrscheinlich_beantwortbar';
}
if (!$hasKnowledge && $hasShopResults) {
return 'nur_shop_treffer_kein_belastbares_fachwissen';
}
return 'keine_belastbaren_daten';
}
private function buildFallbackEscalationBlock(
string $reliabilityState,
bool $hasShopResults,
bool $commerceSearchAttempted,
bool $shopSearchHadSystemFailure,
bool $isTechnicalProductQuestion
): string {
$rules = [];
$stateLineTemplate = $this->config->getFallbackEscalationStateLineTemplate();
if ($stateLineTemplate !== '') {
$rules[] = str_replace('{state}', $reliabilityState, $stateLineTemplate);
}
$rules = array_merge($rules, $this->config->getFallbackEscalationBaseRules());
$rules = array_merge($rules, $this->config->getFallbackEscalationStateRules($reliabilityState));
if ($isTechnicalProductQuestion && !$commerceSearchAttempted && !$shopSearchHadSystemFailure) {
$rules = array_merge($rules, $this->config->getFallbackEscalationWithoutShopCheckRules());
}
if ($hasShopResults && !$commerceSearchAttempted) {
$rules[] = '- Treat shop results as provided context only; do not imply that a live shop check was performed in this run.';
}
if ($rules === []) {
return '';
}
return $this->buildRuleBlock(
$this->config->getFallbackEscalationSectionLabel(),
$rules
);
}
private function buildResponseFormatBlock(
bool $hasShopResults,
bool $isTechnicalProductQuestion,

View File

@@ -149,7 +149,7 @@ final class AgentRunnerConfig
{
return $this->getString(
'messages.no_concrete_shop_query',
'Ich habe keine konkrete Shop-Suchanfrage erkannt. Bitte nenne das Produkt, Zubehör oder die Artikelnummer.'
'Ich kann die Shop-Suche noch nicht belastbar auflösen. Bitte nenne das Produkt, den Messparameter oder das Zubehör etwas konkreter.'
);
}
@@ -173,6 +173,83 @@ final class AgentRunnerConfig
return $this->getString('messages.no_llm_data_received', '❌ Es wurden keine Daten vom LLM empfangen.');
}
public function getNoLlmFallbackMaxShopResults(): int
{
return $this->getInt('no_llm_fallback.max_shop_results', 5);
}
public function getNoLlmFallbackShopOnlyMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.shop_only',
'Ich finde dazu im RAG-Wissen keine belastbare Fachinformation. Aus den Shopdaten ergeben sich folgende Treffer; technische Eignung bitte prüfen:'
);
}
public function getNoLlmFallbackShopWithKnowledgeMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.shop_with_knowledge',
'Es liegen RAG-/Kontexttreffer und Shopdaten vor. Ohne LLM leite ich daraus keine technische Eignung ab. Die Shopdaten zeigen folgende Treffer; technische Eignung bitte prüfen:'
);
}
public function getNoLlmFallbackEscalationMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.escalation',
'Für eine verbindliche Produktauswahl sollte der konkrete Anwendungsfall durch Vertrieb oder Support geprüft werden.'
);
}
public function getNoLlmFallbackKnowledgeOnlyMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.knowledge_only',
'Ich habe Treffer im RAG-Wissen gefunden, aber ohne LLM kann ich daraus keine belastbare fachliche Antwort synthetisieren. Ich gebe deshalb keine sichere Produktaussage aus. Bitte aktiviere das LLM oder konkretisiere die Frage für eine gezielte Prüfung.'
);
}
public function getNoLlmFallbackNoDataMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.no_data',
'Ich finde dazu keine belastbaren Daten in den vorliegenden Quellen. Bitte nenne Produkt, Messparameter, Zubehör oder Anwendungsfall genauer.'
);
}
public function getNoLlmFallbackNoShopResultsWithKnowledgeMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.no_shop_results_with_knowledge',
'Ich finde RAG-/Kontexttreffer, aber keine passenden Shop-Treffer zur aktuellen Suchanfrage. Das ist keine Aussage, dass es das Produkt nicht gibt. Ohne LLM gebe ich keine technische Negativaussage aus; bitte prüfe den Suchbegriff oder den Anwendungsfall gezielter.'
);
}
public function getNoLlmFallbackNoShopResultsNoKnowledgeMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.no_shop_results_no_knowledge',
'Ich finde weder belastbares RAG-Wissen noch passende Shop-Treffer zur aktuellen Suchanfrage. Das ist keine sichere Negativaussage. Bitte nenne Produkt, Messparameter oder Zubehör konkreter.'
);
}
public function getNoLlmFallbackShopUnavailableWithKnowledgeMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.shop_unavailable_with_knowledge',
'Live-Shopdaten konnten nicht geladen werden. Ohne Shop-Check treffe ich keine Aussage zu aktueller Verfügbarkeit, Preis oder Shop-Portfolio. Vorhandenes RAG-Wissen darf nur als fachlicher Kontext verstanden werden.'
);
}
public function getNoLlmFallbackShopUnavailableNoKnowledgeMessage(): string
{
return $this->getString(
'no_llm_fallback.messages.shop_unavailable_no_knowledge',
'Live-Shopdaten konnten nicht geladen werden und ich finde kein belastbares RAG-Wissen. Ich kann daraus keine verlässliche Produkt- oder Verfügbarkeitsaussage ableiten.'
);
}
public function getGenericInternalErrorMessage(): string
{
return $this->getString('messages.generic_internal_error', '❌ Bei der Verarbeitung der Anfrage ist ein interner Fehler aufgetreten.');

View File

@@ -310,6 +310,50 @@ final class PromptBuilderConfig
]);
}
public function getFallbackEscalationSectionLabel(): string
{
return $this->getString('sections.fallback_escalation_label', 'FALLBACK AND ESCALATION RULES');
}
public function getFallbackEscalationStateLineTemplate(): string
{
return $this->getString('fallback_escalation.state_line_template', '- Internal confidence state: {state}.');
}
/**
* @return string[]
*/
public function getFallbackEscalationBaseRules(): array
{
return $this->getStringList('fallback_escalation.base_rules', [
'- Prefer transparent uncertainty over a confident but unsupported answer.',
'- Never present missing or weak evidence as proof that a product, value, accessory, or suitability does not exist.',
'- A negative answer is allowed only when the provided sources explicitly support that negative finding for the asked scope.',
'- If several products, parameters, or accessories could match, ask one focused clarification question instead of guessing.',
'- For risky or binding product selection, state that sales or support should verify the application before a final selection.',
]);
}
/**
* @return string[]
*/
public function getFallbackEscalationStateRules(string $state): array
{
return $this->getStringList('fallback_escalation.states.' . $state, []);
}
/**
* @return string[]
*/
public function getFallbackEscalationWithoutShopCheckRules(): array
{
return $this->getStringList('fallback_escalation.without_shop_check_rules', [
'- If the question is product-related and no live shop check was performed in this run, do not make a portfolio-wide negative statement such as "there is no product".',
'- Phrase missing evidence narrowly, for example: "Im RAG-Wissen finde ich dazu keine belastbare Information."',
'- If useful, say that a shop search can be used to look for matching products, but do not claim shop results were checked unless they are present in the prompt.',
]);
}
public function getResponseFormatSectionLabel(): string
{
return $this->getString('sections.response_format_label', 'RESPONSE FORMAT RULES');