harden information getter services and optimize user msg
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user