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,