p63
This commit is contained in:
265
src/Config/ChatMessagesConfig.php
Normal file
265
src/Config/ChatMessagesConfig.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
final class ChatMessagesConfig
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function __construct(private readonly array $config = [])
|
||||
{
|
||||
}
|
||||
|
||||
public function getSseEmptyPrompt(): string
|
||||
{
|
||||
return $this->string('sse.empty_prompt');
|
||||
}
|
||||
|
||||
public function getSseJobCreateFailed(string $error): string
|
||||
{
|
||||
return $this->render('sse.job_create_failed', ['error' => $error]);
|
||||
}
|
||||
|
||||
public function getSseJobMissing(): string
|
||||
{
|
||||
return $this->string('sse.job_missing');
|
||||
}
|
||||
|
||||
public function getSseStreamInterrupted(): string
|
||||
{
|
||||
return $this->string('sse.stream_interrupted');
|
||||
}
|
||||
|
||||
public function getSseStreamAborted(string $error): string
|
||||
{
|
||||
return $this->render('sse.stream_aborted', ['error' => $error]);
|
||||
}
|
||||
|
||||
public function getSseStreamAbortedEvent(string $message): string
|
||||
{
|
||||
return $this->render('sse.stream_aborted_event', ['message' => $message]);
|
||||
}
|
||||
|
||||
public function getSseUnknownStreamError(): string
|
||||
{
|
||||
return $this->string('sse.unknown_stream_error');
|
||||
}
|
||||
|
||||
public function getSseHistoryFailure(string $message): string
|
||||
{
|
||||
return $this->render('sse.history_failure', ['message' => $message]);
|
||||
}
|
||||
|
||||
public function getSseFatalServerError(string $message, string $file, string $line): string
|
||||
{
|
||||
return $this->render('sse.fatal_server_error', [
|
||||
'message' => $message,
|
||||
'file' => $file,
|
||||
'line' => $line,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getSseFatalUnknownError(): string
|
||||
{
|
||||
return $this->string('sse.fatal_unknown_error');
|
||||
}
|
||||
|
||||
public function getSseFatalUnknownFile(): string
|
||||
{
|
||||
return $this->string('sse.fatal_unknown_file');
|
||||
}
|
||||
|
||||
public function getSseFatalUnknownLine(): string
|
||||
{
|
||||
return $this->string('sse.fatal_unknown_line');
|
||||
}
|
||||
|
||||
public function getSseStreamFailedWithMessage(string $message): string
|
||||
{
|
||||
return $this->render('sse.stream_failed_with_message', ['message' => $message]);
|
||||
}
|
||||
|
||||
public function getSseStreamFailedRetry(): string
|
||||
{
|
||||
return $this->string('sse.stream_failed_retry');
|
||||
}
|
||||
|
||||
public function getSseStreamInterruptedRetry(): string
|
||||
{
|
||||
return $this->string('sse.stream_interrupted_retry');
|
||||
}
|
||||
|
||||
public function getSseJobStale(): string
|
||||
{
|
||||
return $this->string('sse.job_stale');
|
||||
}
|
||||
|
||||
public function getSseClaimExpired(): string
|
||||
{
|
||||
return $this->string('sse.claim.expired');
|
||||
}
|
||||
|
||||
public function getSseClaimInvalid(): string
|
||||
{
|
||||
return $this->string('sse.claim.invalid');
|
||||
}
|
||||
|
||||
public function getSseClaimLockFailed(): string
|
||||
{
|
||||
return $this->string('sse.claim.lock_failed');
|
||||
}
|
||||
|
||||
public function getSseClaimRunning(): string
|
||||
{
|
||||
return $this->string('sse.claim.running');
|
||||
}
|
||||
|
||||
public function getSseClaimInterrupted(): string
|
||||
{
|
||||
return $this->string('sse.claim.interrupted');
|
||||
}
|
||||
|
||||
public function getSseClaimCompleted(): string
|
||||
{
|
||||
return $this->string('sse.claim.completed');
|
||||
}
|
||||
|
||||
public function getSseClaimMissing(): string
|
||||
{
|
||||
return $this->string('sse.claim.missing');
|
||||
}
|
||||
|
||||
public function getSseStorageDirectoryCreateFailed(): string
|
||||
{
|
||||
return $this->string('sse.storage.directory_create_failed');
|
||||
}
|
||||
|
||||
public function getSseStorageWriteFailed(): string
|
||||
{
|
||||
return $this->string('sse.storage.write_failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status:string, errors:list<string>}
|
||||
*/
|
||||
public function validate(): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach ($this->requiredMessagePaths() as $path) {
|
||||
try {
|
||||
$this->string($path);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $errors === [] ? 'OK' : 'ERROR',
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function requiredMessagePaths(): array
|
||||
{
|
||||
return [
|
||||
'sse.empty_prompt',
|
||||
'sse.job_create_failed',
|
||||
'sse.job_missing',
|
||||
'sse.stream_interrupted',
|
||||
'sse.stream_aborted',
|
||||
'sse.stream_aborted_event',
|
||||
'sse.unknown_stream_error',
|
||||
'sse.history_failure',
|
||||
'sse.fatal_server_error',
|
||||
'sse.fatal_unknown_error',
|
||||
'sse.fatal_unknown_file',
|
||||
'sse.fatal_unknown_line',
|
||||
'sse.stream_failed_with_message',
|
||||
'sse.stream_failed_retry',
|
||||
'sse.stream_interrupted_retry',
|
||||
'sse.job_stale',
|
||||
'sse.claim.expired',
|
||||
'sse.claim.invalid',
|
||||
'sse.claim.lock_failed',
|
||||
'sse.claim.running',
|
||||
'sse.claim.interrupted',
|
||||
'sse.claim.completed',
|
||||
'sse.claim.missing',
|
||||
'sse.storage.directory_create_failed',
|
||||
'sse.storage.write_failed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, scalar|null> $parameters
|
||||
*/
|
||||
private function render(string $path, array $parameters): string
|
||||
{
|
||||
$template = $this->string($path);
|
||||
$replacements = [];
|
||||
|
||||
foreach ($parameters as $key => $value) {
|
||||
$replacements['{' . $key . '}'] = $this->normalizePlaceholderValue($value);
|
||||
}
|
||||
|
||||
return strtr($template, $replacements);
|
||||
}
|
||||
|
||||
private function normalizePlaceholderValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
return trim(preg_replace('/\s+/u', ' ', (string) $value) ?? (string) $value);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function string(string $path): string
|
||||
{
|
||||
$value = $this->value($path);
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException(sprintf('RetrieX chat messages config key "%s" must be a non-empty string.', $path));
|
||||
}
|
||||
|
||||
private function value(string $path): mixed
|
||||
{
|
||||
$current = $this->config;
|
||||
|
||||
foreach (explode('.', $path) as $segment) {
|
||||
if (!is_array($current) || !array_key_exists($segment, $current)) {
|
||||
throw new \InvalidArgumentException(sprintf('RetrieX chat messages config key "%s" is required.', $path));
|
||||
}
|
||||
|
||||
$current = $current[$segment];
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ final readonly class ConfigSourceAuditProvider
|
||||
'CommerceQueryParserConfig' => 'retriex.commerce_query.config',
|
||||
'ContextServiceConfig' => 'retriex.context.config',
|
||||
'CatalogIntentConfig' => 'retriex.intent.catalog.config',
|
||||
'ChatMessagesConfig' => 'retriex.chat_messages.config',
|
||||
'DomainVocabularyConfig' => 'retriex.vocabulary.config',
|
||||
'IntentLightConfig' => 'retriex.intent.light.config',
|
||||
'LanguageCleanupConfig' => 'retriex.stopwords.config',
|
||||
|
||||
@@ -546,6 +546,7 @@ final readonly class GenreSourceOfTruthGuard
|
||||
$config = [];
|
||||
$parameterRoots = [
|
||||
'retriex.agent.config' => 'agent',
|
||||
'retriex.chat_messages.config' => 'chat_messages',
|
||||
'retriex.commerce_query.config' => 'commerce_query',
|
||||
'retriex.governance.config' => 'governance',
|
||||
'retriex.intent.commerce.config' => 'intent.commerce',
|
||||
|
||||
@@ -18,6 +18,7 @@ final readonly class RetriexEffectiveConfigProvider
|
||||
private NdjsonHybridRetrieverConfig $retrieverConfig,
|
||||
private DomainVocabularyConfig $domainVocabularyConfig,
|
||||
private AgentRunnerConfig $agentRunnerConfig,
|
||||
private ChatMessagesConfig $chatMessagesConfig,
|
||||
private SearchRepairConfig $searchRepairConfig,
|
||||
private CommerceIntentConfig $commerceIntentConfig,
|
||||
private CommerceQueryParserConfig $commerceQueryParserConfig,
|
||||
@@ -52,6 +53,7 @@ final readonly class RetriexEffectiveConfigProvider
|
||||
'retrieval' => $this->retrievalConfig(),
|
||||
'prompt' => $this->promptConfig(),
|
||||
'agent' => $this->agentConfig(),
|
||||
'chat_messages' => $this->chatMessagesConfig(),
|
||||
'vector' => $this->vectorConfig(),
|
||||
'commerce' => $this->commerceConfig(),
|
||||
'commerce_query' => $this->commerceQueryConfig(),
|
||||
@@ -86,6 +88,7 @@ final readonly class RetriexEffectiveConfigProvider
|
||||
$this->validateRetrieval($config['retrieval'], $errors, $warnings);
|
||||
$this->validatePrompt($config['prompt'], $errors, $warnings);
|
||||
$this->validateAgent($config['agent'], $errors, $warnings);
|
||||
$this->validateChatMessages($errors);
|
||||
$this->validateVector($config['vector'], $errors, $warnings);
|
||||
$this->validateCommerce($config['commerce'], $errors, $warnings);
|
||||
$this->validateCommerceQuery($config['commerce_query'], $errors, $warnings);
|
||||
@@ -602,6 +605,21 @@ final readonly class RetriexEffectiveConfigProvider
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function chatMessagesConfig(): array
|
||||
{
|
||||
return $this->chatMessagesConfig->toArray();
|
||||
}
|
||||
|
||||
private function validateChatMessages(array &$errors): void
|
||||
{
|
||||
$validation = $this->chatMessagesConfig->validate();
|
||||
|
||||
foreach ($validation['errors'] as $error) {
|
||||
$errors[] = $error;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function agentConfig(): array
|
||||
{
|
||||
@@ -1429,6 +1447,7 @@ final readonly class RetriexEffectiveConfigProvider
|
||||
{
|
||||
$configRoots = [
|
||||
'retriex.agent.config' => 'agent',
|
||||
'retriex.chat_messages.config' => 'chat_messages',
|
||||
'retriex.commerce_query.config' => 'commerce_query',
|
||||
'retriex.governance.config' => 'governance',
|
||||
'retriex.intent.commerce.config' => 'intent.commerce',
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Agent\AgentRunner;
|
||||
use App\Config\ChatMessagesConfig;
|
||||
use App\Context\ContextService;
|
||||
use App\Http\ClientIdResolver;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
@@ -27,6 +28,7 @@ final readonly class AskSseController
|
||||
|
||||
public function __construct(
|
||||
private AgentRunner $agentRunner,
|
||||
private ChatMessagesConfig $chatMessages,
|
||||
private ClientIdResolver $clientIdResolver,
|
||||
private ContextService $contextService,
|
||||
private string $projectDir,
|
||||
@@ -40,7 +42,7 @@ final readonly class AskSseController
|
||||
$prompt = trim((string) ($data['prompt'] ?? ''));
|
||||
|
||||
if ($prompt === '') {
|
||||
return new JsonResponse(['error' => 'Empty prompt'], Response::HTTP_BAD_REQUEST);
|
||||
return new JsonResponse(['error' => $this->chatMessages->getSseEmptyPrompt()], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +75,7 @@ final readonly class AskSseController
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return new JsonResponse(
|
||||
['error' => 'Stream job could not be created: ' . $this->formatThrowableForClient($e)],
|
||||
['error' => $this->chatMessages->getSseJobCreateFailed($this->formatThrowableForClient($e))],
|
||||
Response::HTTP_INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
@@ -95,7 +97,7 @@ final readonly class AskSseController
|
||||
if (!is_array($job)) {
|
||||
return new JsonResponse([
|
||||
'status' => 'missing',
|
||||
'message' => 'Der Antwort-Job wurde nicht gefunden.',
|
||||
'message' => $this->chatMessages->getSseJobMissing(),
|
||||
'lastEventId' => 0,
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
@@ -213,8 +215,8 @@ final readonly class AskSseController
|
||||
$this->sendComment('stream-open');
|
||||
|
||||
if ($prompt === '') {
|
||||
$this->markJobStatus($jobId, self::JOB_STATUS_FAILED, 'Empty prompt');
|
||||
$this->sendEvent('error', 'Empty prompt');
|
||||
$this->markJobStatus($jobId, self::JOB_STATUS_FAILED, $this->chatMessages->getSseEmptyPrompt());
|
||||
$this->sendEvent('error', $this->chatMessages->getSseEmptyPrompt());
|
||||
$this->sendDoneEvent();
|
||||
return;
|
||||
}
|
||||
@@ -225,7 +227,7 @@ final readonly class AskSseController
|
||||
$this->markJobStatus(
|
||||
$jobId,
|
||||
self::JOB_STATUS_INTERRUPTED,
|
||||
'Die Verbindung zum Antwort-Stream wurde unterbrochen.'
|
||||
$this->chatMessages->getSseStreamInterrupted()
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -235,11 +237,11 @@ final readonly class AskSseController
|
||||
$this->sendData($chunk, $eventId);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$message = 'Stream abgebrochen: ' . $this->formatThrowableForClient($e);
|
||||
$message = $this->chatMessages->getSseStreamAborted($this->formatThrowableForClient($e));
|
||||
$this->markJobStatus($jobId, self::JOB_STATUS_FAILED, $message);
|
||||
$this->sendEvent(
|
||||
'error',
|
||||
'❌ ' . $message
|
||||
$this->chatMessages->getSseStreamAbortedEvent($message)
|
||||
);
|
||||
|
||||
if ($prompt !== '' && $clientId !== '') {
|
||||
@@ -260,13 +262,13 @@ final readonly class AskSseController
|
||||
$message = trim(preg_replace('/\s+/u', ' ', $message) ?? $message);
|
||||
|
||||
if ($message === '') {
|
||||
$message = 'Unbekannter Streamfehler.';
|
||||
$message = $this->chatMessages->getSseUnknownStreamError();
|
||||
}
|
||||
|
||||
$this->contextService->appendHistory(
|
||||
$clientId,
|
||||
$prompt,
|
||||
'Systemhinweis: Antwort konnte nicht abgeschlossen werden. Ursache: ' . $message
|
||||
$this->chatMessages->getSseHistoryFailure($message)
|
||||
);
|
||||
} catch (Throwable) {
|
||||
// History persistence must never break the SSE error response.
|
||||
@@ -288,11 +290,10 @@ final readonly class AskSseController
|
||||
return;
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'❌ Fataler Serverfehler: %s in %s:%s',
|
||||
(string) ($error['message'] ?? 'unknown error'),
|
||||
(string) ($error['file'] ?? 'unknown file'),
|
||||
(string) ($error['line'] ?? '?')
|
||||
$message = $this->chatMessages->getSseFatalServerError(
|
||||
(string) ($error['message'] ?? $this->chatMessages->getSseFatalUnknownError()),
|
||||
(string) ($error['file'] ?? $this->chatMessages->getSseFatalUnknownFile()),
|
||||
(string) ($error['line'] ?? $this->chatMessages->getSseFatalUnknownLine())
|
||||
);
|
||||
|
||||
$this->markJobStatus($jobId, self::JOB_STATUS_FAILED, $message);
|
||||
@@ -420,13 +421,13 @@ final readonly class AskSseController
|
||||
$directory = $this->jobDirectory();
|
||||
|
||||
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||
throw new \RuntimeException('Stream job directory could not be created.');
|
||||
throw new \RuntimeException($this->chatMessages->getSseStorageDirectoryCreateFailed());
|
||||
}
|
||||
|
||||
$json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if (file_put_contents($this->jobPath($jobId), $json, LOCK_EX) === false) {
|
||||
throw new \RuntimeException('Stream job could not be written.');
|
||||
throw new \RuntimeException($this->chatMessages->getSseStorageWriteFailed());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,8 +665,8 @@ final readonly class AskSseController
|
||||
$this->sendEvent(
|
||||
'error',
|
||||
$message !== ''
|
||||
? 'Der Antwort-Stream ist fehlgeschlagen: ' . $message
|
||||
: 'Der Antwort-Stream ist fehlgeschlagen. Bitte sende die Anfrage erneut.'
|
||||
? $this->chatMessages->getSseStreamFailedWithMessage($message)
|
||||
: $this->chatMessages->getSseStreamFailedRetry()
|
||||
);
|
||||
$this->sendDoneEvent();
|
||||
return;
|
||||
@@ -676,7 +677,7 @@ final readonly class AskSseController
|
||||
'error',
|
||||
$message !== ''
|
||||
? $message
|
||||
: 'Der Antwort-Stream wurde durch einen Verbindungsabbruch unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.'
|
||||
: $this->chatMessages->getSseStreamInterruptedRetry()
|
||||
);
|
||||
$this->sendDoneEvent();
|
||||
return;
|
||||
@@ -857,7 +858,7 @@ final readonly class AskSseController
|
||||
return $job;
|
||||
}
|
||||
|
||||
$message = 'Der Antwort-Job liefert seit längerer Zeit keine neuen Daten. Der Stream wurde beendet, damit die Oberfläche nicht hängen bleibt.';
|
||||
$message = $this->chatMessages->getSseJobStale();
|
||||
$this->markJobStatus($jobId, self::JOB_STATUS_FAILED, $message);
|
||||
|
||||
$freshJob = $this->readJob($jobId);
|
||||
@@ -875,39 +876,40 @@ final readonly class AskSseController
|
||||
$message = is_string($claim['message'] ?? null) ? trim((string) $claim['message']) : '';
|
||||
|
||||
if ($reason === 'expired') {
|
||||
return 'Der Antwort-Job ist abgelaufen. Bitte sende die Anfrage erneut.';
|
||||
return $this->chatMessages->getSseClaimExpired();
|
||||
}
|
||||
|
||||
if ($reason === 'invalid') {
|
||||
return 'Der Antwort-Job ist ungültig. Bitte sende die Anfrage erneut.';
|
||||
return $this->chatMessages->getSseClaimInvalid();
|
||||
}
|
||||
|
||||
if ($reason === 'lock_failed') {
|
||||
return 'Der Antwort-Job ist gerade gesperrt. Bitte sende die Anfrage erneut, falls keine Antwort erscheint.';
|
||||
return $this->chatMessages->getSseClaimLockFailed();
|
||||
}
|
||||
|
||||
if ($reason === 'not_pending') {
|
||||
if ($status === self::JOB_STATUS_RUNNING) {
|
||||
return 'Der Antwort-Stream läuft bereits oder wurde nach einem Verbindungsabbruch erneut geöffnet. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.';
|
||||
return $this->chatMessages->getSseClaimRunning();
|
||||
}
|
||||
|
||||
if ($status === self::JOB_STATUS_INTERRUPTED) {
|
||||
return 'Der Antwort-Stream wurde durch einen Verbindungsabbruch unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.';
|
||||
return $this->chatMessages->getSseClaimInterrupted();
|
||||
}
|
||||
|
||||
if ($status === self::JOB_STATUS_COMPLETED) {
|
||||
return 'Der Antwort-Stream wurde bereits abgeschlossen. Bitte sende eine neue Anfrage, wenn du eine weitere Antwort brauchst.';
|
||||
return $this->chatMessages->getSseClaimCompleted();
|
||||
}
|
||||
|
||||
if ($status === self::JOB_STATUS_FAILED) {
|
||||
return $message !== ''
|
||||
? 'Der Antwort-Stream ist fehlgeschlagen: ' . $message
|
||||
: 'Der Antwort-Stream ist fehlgeschlagen. Bitte sende die Anfrage erneut.';
|
||||
? $this->chatMessages->getSseStreamFailedWithMessage($message)
|
||||
: $this->chatMessages->getSseStreamFailedRetry();
|
||||
}
|
||||
}
|
||||
|
||||
return 'Der Antwort-Job wurde nicht gefunden. Falls deine Verbindung kurz unterbrochen war, sende die Anfrage bitte erneut.';
|
||||
return $this->chatMessages->getSseClaimMissing();
|
||||
}
|
||||
|
||||
private function cleanupExpiredJobs(): void
|
||||
{
|
||||
$directory = $this->jobDirectory();
|
||||
|
||||
Reference in New Issue
Block a user