This commit is contained in:
team 1
2026-05-09 11:10:29 +02:00
parent 71fc5a2501
commit 424aef2575
8 changed files with 437 additions and 30 deletions

View 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;
}
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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();