This commit is contained in:
team 1
2026-05-09 11:24:08 +02:00
parent 424aef2575
commit c327dc4102
7 changed files with 428 additions and 29 deletions

View File

@@ -30,3 +30,46 @@ parameters:
storage:
directory_create_failed: 'Stream job directory could not be created.'
write_failed: 'Stream job could not be written.'
frontend:
document:
title: 'AI Agent'
ui:
header_title: 'KI-Agent'
footer_disclaimer: 'powered by mitho® | RetrieX kann fehlerhafte Ausgaben machen. RetrieX verwendet alle Daten zum Trainieren seiner Modelle.'
buttons:
clear: 'Diesen Chat löschen'
send: 'Send'
abort: 'Abbrechen'
options:
aria_label: 'Chat-Anzeigeoptionen'
status_info: 'Statusinfo anzeigen'
input:
prompt_placeholder: 'Stelle eine Frage'
assistant:
loader: 'Antwort wird vorbereitet…'
aborted: '[aborted]'
history_cleared: 'History cleared.'
source_chips:
live_shop_data: 'Live-Shopdaten'
run_meta:
completed_title: 'Abgeschlossen'
interrupted_title: 'Antwort wurde unterbrochen'
completed_status: 'Status: abgeschlossen'
interrupted_status: 'Status: unterbrochen'
completed_empty_source: 'keine belastbare Datenbasis'
interrupted_empty_source: 'nicht vollständig geprüft'
pending_source_marker: 'wird geprüft'
stream:
incomplete: 'Der Antwort-Stream wurde beendet, bevor die Antwort abgeschlossen werden konnte.'
job_not_found_retry: 'Der Antwort-Job wurde nicht mehr gefunden. Bitte sende die Anfrage erneut.'
failed_retry: 'Der Antwort-Stream ist fehlgeschlagen. Bitte sende die Anfrage erneut.'
interrupted_retry: 'Der Antwort-Stream wurde durch einen Verbindungsabbruch unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.'
missing_retry: 'Der Antwort-Job wurde nicht gefunden. Bitte sende die Anfrage erneut.'
stale_retry: 'Der Antwort-Job liefert seit längerer Zeit keine neuen Daten. Der Stream wurde beendet.'
connection_interrupted_retry: 'Die Verbindung zum Antwort-Stream wurde unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.'
guards:
no_concrete_shop_response_markers:
- 'keine konkrete shop-suchanfrage erkannt'
- 'shop-suche noch nicht belastbar auflösen'
- 'shop-suche noch nicht belastbar aufloesen'

View File

@@ -0,0 +1,99 @@
# RetrieX Patch 64 - Frontend Chat Messages Config
## Ziel
p64 erweitert den mit p63 eingeführten `chat-messages.yaml`-Schnitt auf die sichtbaren Frontend-Chattexte in `public/index.html` und `public/assets/js/base.js`.
Ziel ist, dass die im Browser sichtbaren Chat-UI-, Loader-, Status- und Stream-Fallback-Texte nicht mehr hart in HTML/JavaScript gepflegt werden müssen, sondern zentral über `config/retriex/chat-messages.yaml` konfigurierbar sind.
## Änderungen
- `config/retriex/chat-messages.yaml`
- neuer Bereich `frontend`
- UI-Texte für Titel, Header, Buttons, Placeholder, Footer und Optionen
- Assistant-Hinweise wie Loader, Abbruch- und History-Cleared-Meldung
- Frontend-Stream-Fallback-Meldungen
- Run-Meta-Statuslabels
- Live-Shopdaten-Chip-Label
- konfigurierbare Marker für No-Concrete-Shop-Response-Erkennung
- `src/Config/ChatMessagesConfig.php`
- `getFrontendMessages()` ergänzt
- Validation um Frontend-Message-Pfade erweitert
- List-Validation für konfigurierbare Guard-Marker ergänzt
- `src/Controller/ChatMessagesController.php`
- neuer JSON-Endpunkt `/chat-messages/frontend`
- liefert den `frontend`-Teil aus `chat-messages.yaml` an das Browser-Frontend
- no-store/no-cache Header gesetzt
- `public/assets/js/base.js`
- lädt Chat-Frontend-Messages beim Start aus `/chat-messages/frontend`
- wendet UI-Texte über `data-chat-message-*` Attribute an
- ersetzt sichtbare Loader-/Stream-/Status-/Abbruch-/History-Texte durch YAML-Werte
- No-Concrete-Shop-Response-Marker kommen aus YAML statt aus JS-Stringliteralen
- `public/index.html`
- sichtbare harte UI-Texte entfernt
- stattdessen `data-chat-message-text`, `data-chat-message-placeholder` und `data-chat-message-aria-label`
- Dokumenttitel wird durch `base.js` aus YAML gesetzt
## Bewusste Nicht-Ziele
- p64 verschiebt die bereits YAML-basierten Agent-/Production-UI-Blöcke aus `agent.yaml` noch nicht nach `chat-messages.yaml`.
- p64 ändert keine RAG-, Shop-, Prompt-, Retrieval- oder Scoring-Logik.
- Technische Protokollwerte wie SSE-Eventnamen, Job-Statuswerte, Storage Keys, CSS-Klassen und Debug-/Console-Meldungen bleiben im Code.
## Checks
Lokal geprüft:
```bash
php -l src/Config/ChatMessagesConfig.php
php -l src/Controller/ChatMessagesController.php
php -l src/Controller/AskSseController.php
node --check public/assets/js/base.js
python3 - <<'PY'
import yaml
for path in ['config/retriex/chat-messages.yaml', 'config/services.yaml']:
with open(path, 'r', encoding='utf-8') as f:
yaml.safe_load(f)
print(f'YAML OK: {path}')
PY
```
Nicht ausführbar im entpackten ZIP ohne `vendor/`:
```bash
php bin/console mto:agent:config:validate
```
Fehler:
```text
Dependencies are missing. Try running "composer install".
```
## Empfohlene manuelle Regression
Nach Einspielen im echten Projekt mit Vendor-Abhängigkeiten:
```bash
bin/console mto:agent:config:validate
bin/console mto:agent:regression:test
bin/console mto:agent:config:audit-source --details
bin/console mto:agent:config:audit-patterns --details
```
Browser-Check:
1. Startseite öffnen.
2. Prüfen, ob Titel, Header, Buttons, Placeholder und Footer korrekt erscheinen.
3. Eine normale Anfrage senden und Loader/Statuskarten prüfen.
4. Stream abbrechen und Abbruchmeldung prüfen.
5. Chat löschen und History-Cleared-Meldung prüfen.
6. Einen Stream-/Reconnect-Fehlerfall prüfen, falls reproduzierbar.
## Folgearbeit
Falls wirklich alle chat-sichtbaren Texte in exakt einer Datei liegen sollen, sollte ein späterer Patch die bereits YAML-konfigurierten Agent-UI-Bereiche aus `agent.yaml` nach `chat-messages.yaml` verschieben und die Getter in `AgentRunnerConfig` sauber delegieren oder auf eine Message-spezifische Config-Fassade umstellen.

View File

@@ -1,4 +1,4 @@
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
const chatEl = document.getElementById('chat');
const promptEl = document.getElementById('prompt');
const sendBtn = document.getElementById('send');
@@ -12,6 +12,106 @@ document.addEventListener('DOMContentLoaded', () => {
const JOB_COMPLETION_CATCHUP_GRACE_MS = 10000;
const JOB_CLIENT_STALE_GRACE_MS = 150000;
let chatMessages = {};
function configuredMessage(path, parameters = {}) {
const segments = String(path || '').split('.').filter(Boolean);
let current = chatMessages;
for (const segment of segments) {
if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, segment)) {
return '';
}
current = current[segment];
}
if (typeof current !== 'string') {
return '';
}
return current.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => {
if (!Object.prototype.hasOwnProperty.call(parameters, key)) {
return match;
}
return String(parameters[key] ?? '').replace(/\s+/g, ' ').trim();
});
}
function configuredMessageList(path) {
const segments = String(path || '').split('.').filter(Boolean);
let current = chatMessages;
for (const segment of segments) {
if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, segment)) {
return [];
}
current = current[segment];
}
return Array.isArray(current) ? current.filter((item) => typeof item === 'string' && item.trim() !== '') : [];
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function configuredEmphasis(path) {
const text = configuredMessage(path);
return text === '' ? '' : `<em>${escapeHtml(text)}</em>`;
}
async function loadConfiguredChatMessages() {
try {
const response = await fetch('/chat-messages/frontend', {
method: 'GET',
cache: 'no-store',
headers: {'Accept': 'application/json'},
});
if (!response.ok) {
console.error('Chat messages request failed with status:', response.status);
return;
}
const payload = await response.json();
chatMessages = payload && typeof payload === 'object' ? payload : {};
} catch (err) {
console.error('Chat messages load failed:', err);
}
}
function applyConfiguredUiMessages(root = document) {
const documentTitle = configuredMessage('document.title');
if (documentTitle !== '') {
document.title = documentTitle;
}
root.querySelectorAll('[data-chat-message-text]').forEach((element) => {
element.textContent = configuredMessage(element.getAttribute('data-chat-message-text') || '');
});
root.querySelectorAll('[data-chat-message-placeholder]').forEach((element) => {
element.setAttribute('placeholder', configuredMessage(element.getAttribute('data-chat-message-placeholder') || ''));
});
root.querySelectorAll('[data-chat-message-aria-label]').forEach((element) => {
element.setAttribute('aria-label', configuredMessage(element.getAttribute('data-chat-message-aria-label') || ''));
});
}
await loadConfiguredChatMessages();
applyConfiguredUiMessages();
const state = {
abortRequested: false,
isStreaming: false,
@@ -118,9 +218,8 @@ document.addEventListener('DOMContentLoaded', () => {
function isNoConcreteShopResponse(value) {
const normalized = normalizeContextHintText(value).toLowerCase();
return normalized.includes('keine konkrete shop-suchanfrage erkannt')
|| normalized.includes('shop-suche noch nicht belastbar auflösen')
|| normalized.includes('shop-suche noch nicht belastbar aufloesen');
return configuredMessageList('guards.no_concrete_shop_response_markers')
.some((marker) => normalized.includes(marker.toLowerCase()));
}
function rememberCompletedTurn(userPrompt, assistantText) {
@@ -278,7 +377,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
function addLoader() {
return addMessage('assistant', 'Antwort wird vorbereitet…', 'loader');
return addMessage('assistant', escapeHtml(configuredMessage('assistant.loader')), 'loader');
}
function hasMeaningfulChildContent(element) {
@@ -504,7 +603,7 @@ document.addEventListener('DOMContentLoaded', () => {
let key = canonicalRetriexSourceChipKey(chip.textContent);
if (key === 'shopsystem' || key === 'liveshopdaten') {
chip.textContent = 'Live-Shopdaten';
chip.textContent = configuredMessage('source_chips.live_shop_data');
key = 'liveshopdaten';
}
@@ -643,9 +742,9 @@ document.addEventListener('DOMContentLoaded', () => {
}
const isError = options.state === 'error';
const titleText = isError ? 'Antwort wurde unterbrochen' : 'Abgeschlossen';
const statusText = isError ? 'Status: unterbrochen' : 'Status: abgeschlossen';
const emptySourceText = isError ? 'nicht vollständig geprüft' : 'keine belastbare Datenbasis';
const titleText = isError ? configuredMessage('run_meta.interrupted_title') : configuredMessage('run_meta.completed_title');
const statusText = isError ? configuredMessage('run_meta.interrupted_status') : configuredMessage('run_meta.completed_status');
const emptySourceText = isError ? configuredMessage('run_meta.interrupted_empty_source') : configuredMessage('run_meta.completed_empty_source');
container.querySelectorAll('.retriex-run-meta[data-retriex-meta-id="run-status"]').forEach((card) => {
card.setAttribute('data-retriex-meta-state', isError ? 'error' : 'completed');
@@ -664,7 +763,7 @@ document.addEventListener('DOMContentLoaded', () => {
const emptySource = card.querySelector('.retriex-source-overview__empty');
if (emptySource && emptySource.textContent.trim() === 'wird geprüft') {
if (emptySource && emptySource.textContent.trim() === configuredMessage('run_meta.pending_source_marker')) {
emptySource.textContent = emptySourceText;
}
});
@@ -919,7 +1018,7 @@ document.addEventListener('DOMContentLoaded', () => {
};
const completeWithJobError = (message) => {
appendError(message || 'Der Antwort-Stream wurde beendet, bevor die Antwort abgeschlossen werden konnte.');
appendError(message || configuredMessage('stream.incomplete'));
complete();
};
@@ -937,7 +1036,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!res.ok) {
if (res.status === 404 && Date.now() - lastClientProgressAt > JOB_CLIENT_STALE_GRACE_MS) {
completeWithJobError('Der Antwort-Job wurde nicht mehr gefunden. Bitte sende die Anfrage erneut.');
completeWithJobError(configuredMessage('stream.job_not_found_retry'));
}
return;
}
@@ -952,17 +1051,17 @@ document.addEventListener('DOMContentLoaded', () => {
}
if (status === 'failed') {
completeWithJobError(message || 'Der Antwort-Stream ist fehlgeschlagen. Bitte sende die Anfrage erneut.');
completeWithJobError(message || configuredMessage('stream.failed_retry'));
return;
}
if (status === 'interrupted') {
completeWithJobError(message || 'Der Antwort-Stream wurde durch einen Verbindungsabbruch unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.');
completeWithJobError(message || configuredMessage('stream.interrupted_retry'));
return;
}
if (status === 'missing') {
completeWithJobError(message || 'Der Antwort-Job wurde nicht gefunden. Bitte sende die Anfrage erneut.');
completeWithJobError(message || configuredMessage('stream.missing_retry'));
return;
}
@@ -972,7 +1071,7 @@ document.addEventListener('DOMContentLoaded', () => {
const staleAfterSeconds = parsePositiveInteger(statusPayload?.runningStaleAfterSeconds);
if (updatedAt > 0 && serverTime > 0 && staleAfterSeconds > 0 && serverTime - updatedAt > staleAfterSeconds + 15) {
completeWithJobError(message || 'Der Antwort-Job liefert seit längerer Zeit keine neuen Daten. Der Stream wurde beendet.');
completeWithJobError(message || configuredMessage('stream.stale_retry'));
return;
}
}
@@ -1065,7 +1164,7 @@ document.addEventListener('DOMContentLoaded', () => {
bubble.classList.remove('loader');
const userMessage = 'Die Verbindung zum Antwort-Stream wurde unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.';
const userMessage = configuredMessage('stream.connection_interrupted_retry');
if (raw.trim() !== '') {
const formattedMessage = `<em>${userMessage}</em>`;
@@ -1094,7 +1193,7 @@ document.addEventListener('DOMContentLoaded', () => {
state.completeStream?.();
await releaseStreamResources();
setBusyUi(false);
addMessage('assistant', '<em>[aborted]</em>');
addMessage('assistant', configuredEmphasis('assistant.aborted'));
});
clearBtn.addEventListener('click', async () => {
@@ -1116,6 +1215,6 @@ document.addEventListener('DOMContentLoaded', () => {
console.debug('Could not clear last completed turn:', err);
}
chatEl.innerHTML = '';
addMessage('assistant', '<em>History cleared.</em>');
addMessage('assistant', configuredEmphasis('assistant.history_cleared'));
});
});

View File

@@ -1,8 +1,8 @@
<!DOCTYPE html>
<html lang="en">
<html lang="de">
<head>
<meta charset="UTF-8">
<title>AI Agent</title>
<title></title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Markdown + Sanitizer -->
@@ -25,29 +25,29 @@
<div class="d-flex">
<img src="/assets/img/logo.png" style="max-height: 20px;">
</div>
<div class="text-info fw-bold" style="font-size: 12px">KI-Agent</div>
<div class="text-info fw-bold" style="font-size: 12px" data-chat-message-text="ui.header_title"></div>
</div>
<img src="/assets/img/logo.svg" style="max-height: 20px;">
<div class="spacer"></div>
<button id="clear" class="btn btn-trans">Diesen Chat löschen</button>
<button id="clear" class="btn btn-trans" data-chat-message-text="ui.buttons.clear"></button>
</div>
<div id="ai-cloud" class="ai-cloud d-none"></div>
<div id="chat" class="chat"></div>
<div id="retriex-chat-options" class="retriex-chat-options p-2" aria-label="Chat-Anzeigeoptionen">
<div id="retriex-chat-options" class="retriex-chat-options p-2" data-chat-message-aria-label="ui.options.aria_label">
<label class="retriex-option-toggle" for="toggle-retriex-cards">
<input id="toggle-retriex-cards" type="checkbox">
<span>Statusinfo anzeigen</span>
<span data-chat-message-text="ui.options.status_info"></span>
</label>
</div>
<div class="input-area">
<textarea id="prompt" class="form-control bg-dark" placeholder="Stelle eine Frage"></textarea>
<button id="send" class="btn btn-trans">Send</button>
<button id="abort" class="btn btn-trans" disabled>Abbrechen</button>
<textarea id="prompt" class="form-control bg-dark" data-chat-message-placeholder="ui.input.prompt_placeholder"></textarea>
<button id="send" class="btn btn-trans" data-chat-message-text="ui.buttons.send"></button>
<button id="abort" class="btn btn-trans" disabled data-chat-message-text="ui.buttons.abort"></button>
</div>
<div class="small mt-2 text-center text-secondary">powered by mitho® | RetrieX kann fehlerhafte Ausgaben machen. RetrieX verwendet alle Daten zum Trainieren seiner Modelle.</div>
<div class="small mt-2 text-center text-secondary" data-chat-message-text="ui.footer_disclaimer"></div>
</div>
</body>

View File

@@ -0,0 +1,44 @@
# ==========================================
# QWEN 3 8B - RAG OPTIMIZED CONFIGURATION
# ==========================================
# Basis-Modell festlegen
FROM qwen3:8b
# --- TECHNISCHE PARAMETER (Sampling & Performance) ---
# Schaltet Kreativität aus, um Halluzinationen in RAG zu vermeiden
PARAMETER temperature 0.0
# Sorgt für deterministische (gleichbleibende) Antworten
PARAMETER seed 42
# Erweitert den Arbeitsspeicher für Dokumente (16k Token)
PARAMETER num_ctx 16384
# Verhindert Wort-Wiederholungen aus den Quelltexten
PARAMETER repeat_penalty 1.1
# Definiert, wie viele Tokens gleichzeitig verarbeitet werden
PARAMETER num_predict 2048
# --- SYSTEM PROMPT (Verhalten & Regeln) ---
SYSTEM """
Du bist ein spezialisierter RAG-Analyst für Qwen 3. Deine einzige Aufgabe ist die präzise Extraktion von Informationen aus dem bereitgestellten KONTEXT.
### STRIKTE ARBEITSANWEISUNGEN:
1. QUELLEN-TREUE: Antworte AUSSCHLIESSLICH basierend auf den übergebenen Dokumenten.
2. UNWISSENHEIT: Wenn die Information nicht im Kontext steht, antworte exakt mit: "Information nicht in den Dokumenten vorhanden."
3. KEINE HALLUZINATIONEN: Erfinde keine Fakten und ergänze kein externes Wissen.
4. ZITIERPFLICHT: Füge hinter jede Fakten-Aussage die Quelle in eckigen Klammern an, z.B. [Dokument Name, Seite X].
5. FORMATIERUNG: Nutze Markdown-Tabellen für Datenvergleiche und Bulletpoints für Listen.
### SPRACHE & TON:
- Sprache: Deutsch (Sachlich, professionell).
- Keine Höflichkeitsfloskeln am Anfang oder Ende.
- Direkt auf den Punkt kommen.
"""
# --- TEMPLATE (ChatML Struktur) ---
TEMPLATE """{{ if .System }}<|im_start|>system
{{ .System }}<|im_end|>
{{ end }}{{ if .Prompt }}<|im_start|>user
{{ .Prompt }}<|im_end|>
{{ end }}<|im_start|>assistant
{{ .Response }}<|im_end|>"""

View File

@@ -142,6 +142,20 @@ final class ChatMessagesConfig
return $this->string('sse.storage.write_failed');
}
/**
* @return array<string, mixed>
*/
public function getFrontendMessages(): array
{
$messages = $this->value('frontend');
if (is_array($messages)) {
return $messages;
}
throw new \InvalidArgumentException('RetrieX chat messages config key "frontend" must be an array.');
}
/**
* @return array<string, mixed>
*/
@@ -165,6 +179,14 @@ final class ChatMessagesConfig
}
}
foreach ($this->requiredStringListPaths() as $path) {
try {
$this->stringList($path);
} catch (\InvalidArgumentException $e) {
$errors[] = $e->getMessage();
}
}
return [
'status' => $errors === [] ? 'OK' : 'ERROR',
'errors' => $errors,
@@ -202,6 +224,43 @@ final class ChatMessagesConfig
'sse.claim.missing',
'sse.storage.directory_create_failed',
'sse.storage.write_failed',
'frontend.document.title',
'frontend.ui.header_title',
'frontend.ui.footer_disclaimer',
'frontend.ui.buttons.clear',
'frontend.ui.buttons.send',
'frontend.ui.buttons.abort',
'frontend.ui.options.aria_label',
'frontend.ui.options.status_info',
'frontend.ui.input.prompt_placeholder',
'frontend.assistant.loader',
'frontend.assistant.aborted',
'frontend.assistant.history_cleared',
'frontend.source_chips.live_shop_data',
'frontend.run_meta.completed_title',
'frontend.run_meta.interrupted_title',
'frontend.run_meta.completed_status',
'frontend.run_meta.interrupted_status',
'frontend.run_meta.completed_empty_source',
'frontend.run_meta.interrupted_empty_source',
'frontend.run_meta.pending_source_marker',
'frontend.stream.incomplete',
'frontend.stream.job_not_found_retry',
'frontend.stream.failed_retry',
'frontend.stream.interrupted_retry',
'frontend.stream.missing_retry',
'frontend.stream.stale_retry',
'frontend.stream.connection_interrupted_retry',
];
}
/**
* @return list<string>
*/
private function requiredStringListPaths(): array
{
return [
'frontend.guards.no_concrete_shop_response_markers',
];
}
@@ -237,6 +296,34 @@ final class ChatMessagesConfig
return '';
}
/**
* @return list<string>
*/
private function stringList(string $path): array
{
$value = $this->value($path);
if (!is_array($value)) {
throw new \InvalidArgumentException(sprintf('RetrieX chat messages config key "%s" must be a list of non-empty strings.', $path));
}
$items = [];
foreach ($value as $item) {
if (!is_string($item) || trim($item) === '') {
throw new \InvalidArgumentException(sprintf('RetrieX chat messages config key "%s" must contain only non-empty strings.', $path));
}
$items[] = $item;
}
if ($items === []) {
throw new \InvalidArgumentException(sprintf('RetrieX chat messages config key "%s" must not be empty.', $path));
}
return $items;
}
private function string(string $path): string
{
$value = $this->value($path);

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Config\ChatMessagesConfig;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
final readonly class ChatMessagesController
{
public function __construct(private ChatMessagesConfig $chatMessages)
{
}
#[Route('/chat-messages/frontend', name: 'chat_messages_frontend', methods: ['GET'])]
public function frontend(): JsonResponse
{
$response = new JsonResponse($this->chatMessages->getFrontendMessages());
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', '0');
return $response;
}
}