This commit is contained in:
team 1
2026-05-12 10:56:50 +02:00
parent feaec9bbaf
commit 6dced1c4df
7 changed files with 1409 additions and 5 deletions

731
RETRIEX-EVAL-CASE-HOWTO.md Normal file
View File

@@ -0,0 +1,731 @@
# RetrieX How-to: Neue Eval-Cases korrekt erstellen
Dieses How-to beschreibt, wie neue Regressionstests für die RetrieX Eval-Suite über den Admin-Bereich angelegt werden.
Ziel ist, neue rote oder fachlich wichtige Fälle dauerhaft abzusichern, ohne direkt Core-Logik, Retrieval-Regeln oder Shopquery-Heuristiken zu verändern.
## Einstieg
Admin-Pfad:
```text
/admin/evals/
```
Im Bereich **„Eval-Case erstellen“** können neue Cases für folgende Typen angelegt werden:
```text
retrieval
shop_query
followup
answer_guard
```
Nach dem Speichern wird der Case in die passende Datei geschrieben:
```text
tests/evals/cases/retrieval.ndjson
tests/evals/cases/shop_query.ndjson
tests/evals/cases/followup.ndjson
tests/evals/cases/answer_guard.ndjson
```
---
## Grundregel
Ein guter Eval-Case prüft genau **einen klaren Sachverhalt**.
Gut:
```json
{
"expected_query": "testomat 808",
"must_not_include_terms": [
"indikator",
"300"
]
}
```
Weniger gut:
```json
{
"expected_query": "testomat 808",
"must_include_terms": [
"testomat",
"808",
"gerät",
"preis",
"wasserhärte"
],
"must_not_include_terms": [
"indikator",
"300",
"testomat 2000",
"chlor",
"versand"
]
}
```
Je kleiner und eindeutiger der Case ist, desto besser eignet er sich als Regressionstest.
---
# Felder im Admin
## 1. Eval-Typ
Wähle den Typ passend zum Ziel des Tests.
```text
retrieval → prüft, ob die richtigen RAG-Dokumente/Chunks gefunden werden
shop_query → prüft, welche Shopquery aus einem direkten Prompt entsteht
followup → prüft, welche Shopquery aus Prompt + Chatverlauf entsteht
answer_guard → prüft No-Answer-, Nicht-Halluzinations- oder Evidenzfälle
```
Faustregel:
```text
Wird das richtige Dokument gefunden? → retrieval
Wird die richtige Shopquery erzeugt? → shop_query
Versteht RetrieX die Folgefrage im Verlauf? → followup
Erfindet RetrieX nichts bei schwacher Evidenz? → answer_guard
```
---
## 2. Neue Case-ID
Die Case-ID muss eindeutig sein und darf nur folgende Zeichen enthalten:
```text
Buchstaben
Zahlen
_
-
```
Gute Beispiele:
```text
retrieval_semantic_chlor_clt_001
shop_query_indicator_300_exact_002
followup_main_device_price_002
answer_guard_unknown_medium_001
```
Nicht verwenden:
```text
Test 1
shop query indikator 300
gerät/frage/neue-version
```
Empfohlenes Schema:
```text
<typ>_<thema>_<ziel>_<nummer>
```
Beispiel:
```text
followup_testomat808_device_price_001
```
---
## 3. Prompt
Hier kommt exakt der Nutzerprompt hinein, der getestet werden soll.
Beispiele:
```text
welches geraet ist fuer chlorueberwachung gedacht
```
```text
was kostet der indikator
```
```text
und was kostet das gerät selber
```
```text
welcher testomat misst drachenblut
```
Der Prompt sollte möglichst so eingetragen werden, wie er real im Chat vorkommt. Tippfehler dürfen bewusst enthalten sein, wenn genau dieses Verhalten abgesichert werden soll.
---
## 4. Assert-JSON
Das Assert-JSON beschreibt, was der Test prüfen soll.
Das Feld muss immer ein gültiges JSON-Objekt sein:
```json
{
}
```
Wichtig:
- Keine Kommentare im JSON
- Keine trailing commas
- Doppelte Anführungszeichen verwenden
- Das Feld muss ein Objekt `{ ... }` sein, kein Array
---
# Eval-Typen und Beispiele
## A) Retrieval-Case
Retrieval-Cases prüfen, ob die richtigen RAG-Dokumente oder Chunks gefunden werden.
### Minimaler positiver Retrieval-Case
```json
{
"min_results": 1
}
```
### Retrieval-Case mit erwarteter Dokument-ID
```json
{
"min_results": 1,
"must_include_one_of_document_ids": [
"DOKUMENT-ID-HIER"
]
}
```
### Retrieval-Case mit mehreren möglichen Ziel-Dokumenten
```json
{
"min_results": 1,
"must_include_one_of_document_ids": [
"DOKUMENT-ID-1",
"DOKUMENT-ID-2"
]
}
```
### Retrieval-Case mit Pflichtbegriffen
```json
{
"min_results": 1,
"must_include_any_terms": [
"lieferung",
"versand"
]
}
```
### Retrieval-Case mit verbotenen Dokumenten
```json
{
"min_results": 1,
"must_not_include_document_ids": [
"FALSCHE-DOKUMENT-ID"
]
}
```
### Retrieval-Case für No-Result / Unsinn
```json
{
"max_results": 0
}
```
### Empfohlene Retrieval-Struktur
```json
{
"min_results": 1,
"must_include_one_of_document_ids": [
"DOKUMENT-ID-HIER"
],
"must_include_any_terms": [
"wichtiger fachbegriff",
"produktname"
]
}
```
---
## B) Shopquery-Case
Shopquery-Cases prüfen, welche Shopquery aus einem direkten Prompt entsteht.
### Exakte Shopquery
Prompt:
```text
was kostet der Testomat 808 Indikator 300
```
Assert-JSON:
```json
{
"expected_query": "testomat 808 300 indikator"
}
```
### Shopquery mit Pflicht- und Verbotsbegriffen
```json
{
"must_include_terms": [
"testomat",
"808",
"300",
"indikator"
],
"must_not_include_terms": [
"300 s",
"301",
"302",
"303"
]
}
```
### Query darf nicht auf Noise fallen
```json
{
"must_not_equal_query": "information"
}
```
### Multi-Produkt- oder Link-Follow-up mit Einzelqueries
```json
{
"expected_individual_queries": [
"testomat 2000 self clean",
"testomat 2000 cal",
"testomat 808"
],
"expected_individual_queries_exact": true
}
```
### Empfehlung für Shopquery-Cases
Nicht jeden Case sofort zu streng mit `expected_query` absichern. Bei noch variabler Query-Bildung ist oft besser:
```json
{
"must_include_terms": [
"testomat",
"808",
"sio2"
],
"must_not_include_terms": [
"gerät",
"möchte",
"messen"
]
}
```
`expected_query` nur verwenden, wenn die Query bereits stabil und bewusst exakt sein soll.
---
## C) Follow-up-Case
Follow-up-Cases prüfen, ob RetrieX den Verlauf korrekt nutzt.
Bei `followup` ist **History-JSON praktisch Pflicht**, weil sonst kein echter Verlauf getestet wird.
### Beispiel: Indikatorpreis nach Verlauf
Prompt:
```text
was kostet der indikator
```
History-JSON:
```json
[
{
"prompt": "Was ist der niedrigste Grenzwert für die Wasserhärte, welcher mit einem Testomaten überwacht werden kann?",
"answer": "Der niedrigste Grenzwert für die Wasserhärte beträgt 0,02 °dH. Dieser Wert wird vom Testomat 808 gemessen."
},
{
"prompt": "mit welchem indikator",
"answer": "Der niedrigste messbare Grenzwert für Wasserhärte mit dem Testomat 808 wird mit dem Indikatortyp 300 erreicht."
}
]
```
Assert-JSON:
```json
{
"expected_query": "testomat 808 300 indikator",
"must_include_terms": [
"testomat",
"808",
"300",
"indikator"
],
"must_not_include_terms": [
"300 s",
"301",
"302",
"303",
"testomat 2000"
]
}
```
### Beispiel: Wechsel vom Indikator zurück zum Hauptgerät
Prompt:
```text
und was kostet das gerät selber
```
History-JSON:
```json
[
{
"prompt": "was kostet der indikator",
"answer": "Shop-Suche abgeschlossen. Gesendete Suchquery: testomat 808 300 indikator. Testomat® 808 Indikator 300 500 ml, Produkt-Nummer 141001. Testomat® 808 Indikator 300 2 x 100 ml, Produkt-Nummer 140001. Der zugehörige Testomat ist Testomat 808."
}
]
```
Assert-JSON:
```json
{
"expected_query": "testomat 808",
"must_include_terms": [
"testomat",
"808"
],
"must_not_include_terms": [
"indikator",
"300",
"141001",
"140001"
]
}
```
### Empfehlung für Follow-up-Cases
Die History sollte genau die Informationen enthalten, die der echte Chat vorher hatte.
Nicht zu wenig:
```text
Nur "Indikator 300" ohne Geräteanker kann zu unklar sein.
```
Nicht zu viel:
```text
Ein kompletter langer Chatverlauf kann den Case unnötig instabil machen.
```
Gut ist ein kurzer, fachlich relevanter Auszug.
---
## D) Answer-Guard-Case
Answer-Guard-Cases prüfen, dass RetrieX bei Unsinn, schwacher Evidenz oder falschen Zuordnungen nichts erfindet.
### Unsinn soll keine Treffer liefern
Prompt:
```text
dsgfsdgfsdgf
```
Assert-JSON:
```json
{
"max_results": 0
}
```
### Erfundenes Medium soll nicht als echtes Produkt beantwortet werden
Prompt:
```text
welcher testomat misst drachenblut
```
Assert-JSON:
```json
{
"must_not_include_terms": [
"drachenblut"
]
}
```
### Falsches Dokument darf nicht gezogen werden
```json
{
"min_results": 1,
"must_not_include_document_ids": [
"FALSCHE-DOKUMENT-ID"
]
}
```
### Empfehlung für Answer-Guard-Cases
Bei Answer-Guard-Cases möglichst nicht auf einzelne Wörter im kompletten Retrieval-Text überreagieren. Besser sind:
```text
Dokument-IDs
klare Produktnamen
klare verbotene Zielbegriffe
max_results bei Unsinn
```
Ein Wort irgendwo im Retrieval-Kontext ist nicht automatisch ein fachlicher Fehler.
---
# Optionales Feld: History-JSON
History-JSON wird vor allem für `followup` verwendet.
Format:
```json
[
{
"prompt": "vorherige Nutzerfrage",
"answer": "vorherige Antwort oder relevanter Auszug"
}
]
```
Mehrere Turns:
```json
[
{
"prompt": "erste Frage",
"answer": "erste Antwort"
},
{
"prompt": "zweite Frage",
"answer": "zweite Antwort"
}
]
```
Wichtig:
```text
History-JSON ist ein Array [...]
Assert-JSON ist ein Objekt {...}
```
---
# Optionales Feld: Request Context Hint
Dieses Feld kann meistens leer bleiben.
Es ist nur sinnvoll, wenn ein Case zusätzlichen Kontext simulieren soll, der nicht sauber über History abbildbar ist.
Beispiel:
```text
Sichtbare Shop-Ergebnisse enthalten Testomat 808 und Testomat 808 Indikator 300.
Der Nutzer fragt nach dem Gerät selber.
```
Empfehlung:
```text
Für normale Regressionen lieber History-JSON verwenden.
Request Context Hint nur für Spezialfälle nutzen.
```
---
# Vollständiges Beispiel: Follow-up-Gerätepreis
## Eval-Typ
```text
followup
```
## Neue Case-ID
```text
followup_testomat808_main_device_price_002
```
## Prompt
```text
und was kostet das gerät selber
```
## Assert-JSON
```json
{
"expected_query": "testomat 808",
"must_include_terms": [
"testomat",
"808"
],
"must_not_include_terms": [
"indikator",
"300",
"141001",
"140001"
]
}
```
## History-JSON
```json
[
{
"prompt": "was kostet der indikator",
"answer": "Shop-Suche abgeschlossen. Gesendete Suchquery: testomat 808 300 indikator. Testomat® 808 Indikator 300 500 ml, Produkt-Nummer 141001. Testomat® 808 Indikator 300 2 x 100 ml, Produkt-Nummer 140001. Der zugehörige Testomat ist Testomat 808."
}
]
```
## Request Context Hint
Leer lassen.
---
# Nach dem Speichern prüfen
Nach dem Speichern sollte der passende Eval-Typ ausgeführt werden.
Im Admin:
```text
/admin/evals/
```
Oder per CLI:
```bash
php bin/console mto:agent:config:validate
php bin/console mto:agent:eval:run retrieval
php bin/console mto:agent:eval:run shop_query
php bin/console mto:agent:eval:run followup
php bin/console mto:agent:eval:run answer_guard
```
Für einen einzelnen Typ:
```bash
php bin/console mto:agent:eval:run followup
```
---
# Praktische Checkliste
Vor dem Speichern prüfen:
```text
[ ] Eval-Typ passt zum Ziel
[ ] Case-ID ist eindeutig
[ ] Case-ID enthält nur Buchstaben, Zahlen, _ oder -
[ ] Prompt ist realistisch und exakt
[ ] Assert-JSON ist gültiges JSON-Objekt
[ ] History-JSON ist bei Follow-up-Cases vorhanden
[ ] History-JSON ist gültiges JSON-Array
[ ] Der Case prüft nur einen klaren Sachverhalt
[ ] Assertions sind nicht unnötig streng
[ ] Nach dem Speichern läuft der passende Eval-Typ grün
```
---
# Wann ein neuer Eval-Case angelegt werden sollte
Ein neuer Case ist sinnvoll, wenn:
```text
ein realer Prompt rot war
ein wichtiger grüner Flow dauerhaft abgesichert werden soll
ein Tippfehler-/Noise-Fall stabil bleiben soll
eine Produktidentität nicht verloren gehen darf
eine falsche Dokumentzuordnung verhindert werden soll
eine No-Answer-Situation nicht halluzinieren darf
```
Kein neuer Case ist nötig, wenn:
```text
nur die Formulierung einer Antwort leicht anders war
der Prompt fachlich nicht relevant ist
die Erwartung nicht eindeutig definiert werden kann
der Case mehrere unabhängige Dinge gleichzeitig prüfen würde
```
---
# Leitlinie
Ab RetrieX v1.6.2 gilt:
```text
Keine neue Genauigkeitslogik ohne konkreten roten oder fachlich wichtigen Eval-Fall.
```
Daher sollten neue Optimierungen möglichst immer so ablaufen:
```text
1. Prompt testen
2. Verhalten bewerten
3. Wenn wichtig: Eval-Case anlegen
4. Eval grün bekommen
5. Erst danach Logik, YAML oder Parameter ändern
```

View File

@@ -0,0 +1,44 @@
# RetrieX Patch p100d Admin Eval Prompt Context
Status: patch-only follow-up for p100 Admin Eval UX.
## Goal
Make eval results easier to understand in the Admin UI by showing the actual case prompt directly next to the case id. For follow-up and shopquery cases, show a compact history/context preview as well.
## Changes
- Admin eval result table now displays the case prompt below the case id.
- Follow-up/shopquery eval details now include a compact history preview.
- Admin eval result table shows history/context in a collapsible section when available.
## Files changed
- `src/Eval/ShopQueryEvalRunner.php`
- `templates/admin/evals/index.html.twig`
## Non-goals
No production answer logic is changed:
- no retrieval logic changes
- no shopquery logic changes
- no follow-up logic changes
- no answer-guard logic changes
- no eval assertion changes
- no YAML or parameter changes
- no database migration
## Validation
Recommended after applying:
```bash
php bin/console mto:agent:config:validate
php bin/console mto:agent:eval:run retrieval
php bin/console mto:agent:eval:run shop_query
php bin/console mto:agent:eval:run followup
php bin/console mto:agent:eval:run answer_guard
```
Then open `/admin/evals/` and verify that each result row shows the case prompt and that follow-up/shopquery rows can reveal context/history.

View File

@@ -0,0 +1,66 @@
# RetrieX Patch p101 - Admin Eval Case Creator
## Ziel
p101 ergänzt die bestehende Admin Eval Suite um einen kleinen Case-Creator, damit neue Regression-Cases direkt aus dem Admin heraus in die passenden NDJSON-Dateien geschrieben werden können.
Der Patch baut auf dem grünen p100/p100a/p100b/p100c/p100d-Stand auf und verändert keine produktive RAG-, Shopquery-, Follow-up- oder Antwortlogik.
## Änderungen
- Neue POST-Route im Admin:
- `/admin/evals/case/create`
- Route-Name: `admin_evals_case_create`
- `EvalAdminService::createCase()` zum validierten Schreiben neuer Eval-Cases.
- Neues Formular auf `/admin/evals/`:
- Eval-Typ
- Case-ID
- Prompt
- Assert-JSON
- optionales History-JSON
- optionaler Request Context Hint
- Button pro Report-Result:
- `Als neuen Case vorbereiten`
- übernimmt Prompt, Typ, History-Vorschau, Query oder Dokument-ID als Vorlage in den Creator.
- JSON-/ID-Validierung vor dem Schreiben.
- Duplicate-Guard über alle Eval-Typen.
## Geschriebene Dateien
Neue Cases werden an folgende Dateien angehängt:
- `tests/evals/cases/retrieval.ndjson`
- `tests/evals/cases/shop_query.ndjson`
- `tests/evals/cases/followup.ndjson`
- `tests/evals/cases/answer_guard.ndjson`
## Sicherheit / Scope
Nicht geändert:
- keine Retrieval-Gewichte
- keine Shopquery-Logik
- keine Follow-up-Logik
- keine Answer-Guard-Logik
- keine Prompt-/YAML-/Parameteränderung
- keine Migration
## Manuelle Prüfung
```bash
php bin/console mto:agent:config:validate
php bin/console mto:agent:eval:run retrieval
php bin/console mto:agent:eval:run shop_query
php bin/console mto:agent:eval:run followup
php bin/console mto:agent:eval:run answer_guard
```
Zusätzlich im Admin:
1. `/admin/evals/` öffnen.
2. Einen Eval laufen lassen.
3. Bei einem Result `Als neuen Case vorbereiten` klicken.
4. Case-ID anpassen bzw. prüfen.
5. Assert-JSON prüfen.
6. Speichern.
7. Den betroffenen Eval-Typ erneut laufen lassen.

View File

@@ -67,4 +67,45 @@ final class AdminEvalController extends AbstractController
'type' => $type, 'type' => $type,
]); ]);
} }
#[Route('/case/create', name: 'admin_evals_case_create', methods: ['POST'])]
public function createCase(Request $request, EvalAdminService $evals): Response
{
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_KNOWLEDGE_ADMIN);
if (!$this->isCsrfTokenValid('admin_eval_case_create', (string) $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
$type = trim((string) $request->request->get('type', 'retrieval'));
try {
$created = $evals->createCase(
type: $type,
id: (string) $request->request->get('id', ''),
prompt: (string) $request->request->get('prompt', ''),
assertJson: (string) $request->request->get('assert_json', ''),
historyJson: (string) $request->request->get('history_json', ''),
requestContextHint: (string) $request->request->get('request_context_hint', ''),
);
$type = (string) ($created['type'] ?? $type);
$this->addFlash(
'success',
sprintf('Eval-Case "%s" wurde in %s.ndjson gespeichert.', (string) ($created['id'] ?? ''), $type)
);
} catch (\Throwable $e) {
$this->addFlash('danger', $e->getMessage());
}
if (!in_array($type, $evals->supportedTypeNames(), true)) {
$type = 'retrieval';
}
return $this->redirectToRoute('admin_evals_index', [
'type' => $type,
]);
}
} }

View File

@@ -74,6 +74,7 @@ final readonly class ShopQueryEvalRunner
details: [ details: [
'prompt' => $case->prompt, 'prompt' => $case->prompt,
'history_turns' => count($case->history), 'history_turns' => count($case->history),
'history' => $this->buildHistoryPreview($case->history),
'has_request_context_hint' => $case->requestContextHint !== '', 'has_request_context_hint' => $case->requestContextHint !== '',
'query' => $shopMeta['query'], 'query' => $shopMeta['query'],
'individual_queries' => $shopMeta['individual_queries'], 'individual_queries' => $shopMeta['individual_queries'],
@@ -82,6 +83,31 @@ final readonly class ShopQueryEvalRunner
); );
} }
/**
* @param array<int, array{prompt:string,answer:string}> $history
* @return array<int, array{prompt:string,answer_preview:string}>
*/
private function buildHistoryPreview(array $history): array
{
$preview = [];
foreach ($history as $turn) {
$prompt = trim((string) ($turn['prompt'] ?? ''));
$answer = trim((string) ($turn['answer'] ?? ''));
if ($prompt === '' && $answer === '') {
continue;
}
$preview[] = [
'prompt' => $prompt !== '' ? $prompt : 'Eval-Kontext',
'answer_preview' => $this->previewText($answer, 260),
];
}
return $preview;
}
private function buildUserId(EvalCase $case): string private function buildUserId(EvalCase $case): string
{ {
$safeId = preg_replace('/[^a-zA-Z0-9_-]+/', '_', $case->id) ?? $case->id; $safeId = preg_replace('/[^a-zA-Z0-9_-]+/', '_', $case->id) ?? $case->id;
@@ -349,14 +375,15 @@ final readonly class ShopQueryEvalRunner
return array_values(array_unique($out)); return array_values(array_unique($out));
} }
private function previewText(string $value): string private function previewText(string $value, int $maxLength = 1200): string
{ {
$value = $this->normalizeOneLine($value); $value = $this->normalizeOneLine($value);
$maxLength = max(40, $maxLength);
if (mb_strlen($value, 'UTF-8') <= 1200) { if (mb_strlen($value, 'UTF-8') <= $maxLength) {
return $value; return $value;
} }
return rtrim(mb_substr($value, 0, 1200, 'UTF-8')) . '...'; return rtrim(mb_substr($value, 0, $maxLength, 'UTF-8')) . '...';
} }
} }

View File

@@ -145,6 +145,83 @@ final readonly class EvalAdminService
} }
/**
* @return array{type:string,id:string,path:string,row:array<string,mixed>,case_count:int}
*/
public function createCase(
string $type,
string $id,
string $prompt,
string $assertJson,
string $historyJson = '',
string $requestContextHint = '',
): array {
$type = $this->assertSupportedType($type);
$id = $this->normalizeNewCaseId($id);
$prompt = trim($prompt);
$requestContextHint = trim($requestContextHint);
if ($prompt === '') {
throw new \InvalidArgumentException('Der Eval-Prompt darf nicht leer sein.');
}
if ($this->caseIdExists($id)) {
throw new \RuntimeException(sprintf(
'Ein Eval-Case mit der ID "%s" existiert bereits. Bitte eine neue ID verwenden.',
$id
));
}
$assert = $this->decodeJsonObject($assertJson, 'Assert-JSON');
$history = $this->decodeHistoryJson($historyJson);
$row = [
'id' => $id,
'type' => $type,
'prompt' => $prompt,
'assert' => $assert,
];
if ($history !== []) {
$row['history'] = $history;
}
if ($requestContextHint !== '') {
$row['request_context_hint'] = $requestContextHint;
}
// Reuse the regular DTO validation before writing the case file.
EvalCase::fromArray($row);
$path = $this->caseFilePath($type);
$line = json_encode(
$row,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
);
$prefix = '';
if (is_file($path) && filesize($path) > 0) {
$contents = file_get_contents($path);
if (is_string($contents) && $contents !== '' && !str_ends_with($contents, "\n")) {
$prefix = "\n";
}
}
$written = file_put_contents($path, $prefix . $line . PHP_EOL, FILE_APPEND | LOCK_EX);
if ($written === false) {
throw new \RuntimeException(sprintf('Eval-Case-Datei konnte nicht geschrieben werden: %s', $path));
}
return [
'type' => $type,
'id' => $id,
'path' => $path,
'row' => $row,
'case_count' => count($this->loadCases($type)),
];
}
/** /**
* @param array<int, EvalCase> $cases * @param array<int, EvalCase> $cases
* @return array<int, EvalCase> * @return array<int, EvalCase>
@@ -249,6 +326,123 @@ final readonly class EvalAdminService
return $decoded; return $decoded;
} }
private function normalizeNewCaseId(string $id): string
{
$id = trim($id);
if ($id === '') {
throw new \InvalidArgumentException('Die Eval-Case-ID darf nicht leer sein.');
}
if (preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/', $id) !== 1) {
throw new \InvalidArgumentException(
'Die Eval-Case-ID darf nur Buchstaben, Zahlen, Unterstriche und Bindestriche enthalten und muss mit einem Buchstaben oder einer Zahl beginnen.'
);
}
return $id;
}
private function caseIdExists(string $id): bool
{
foreach (array_keys(self::TYPES) as $type) {
foreach ($this->loadCases($type) as $case) {
if ($case->id === $id) {
return true;
}
}
}
return false;
}
/**
* @return array<string, mixed>
*/
private function decodeJsonObject(string $json, string $label): array
{
$json = trim($json);
if ($json === '') {
return [];
}
try {
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \InvalidArgumentException(sprintf('%s ist ungültig: %s', $label, $e->getMessage()));
}
if (!is_array($decoded)) {
throw new \InvalidArgumentException(sprintf('%s muss ein JSON-Objekt sein.', $label));
}
return $decoded;
}
/**
* @return array<int, array{prompt:string,answer:string}>
*/
private function decodeHistoryJson(string $json): array
{
$json = trim($json);
if ($json === '') {
return [];
}
try {
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \InvalidArgumentException(sprintf('History-JSON ist ungültig: %s', $e->getMessage()));
}
if (!is_array($decoded)) {
throw new \InvalidArgumentException('History-JSON muss eine JSON-Liste sein.');
}
$history = [];
foreach ($decoded as $entry) {
if (is_string($entry)) {
$entry = trim($entry);
if ($entry !== '') {
$history[] = [
'prompt' => 'Eval-Kontext',
'answer' => $entry,
];
}
continue;
}
if (!is_array($entry)) {
continue;
}
$prompt = trim((string) ($entry['prompt'] ?? ''));
$answer = trim((string) ($entry['answer'] ?? $entry['response'] ?? $entry['answer_preview'] ?? ''));
if ($prompt === '' && $answer === '') {
continue;
}
$history[] = [
'prompt' => $prompt !== '' ? $prompt : 'Eval-Kontext',
'answer' => $answer,
];
}
return $history;
}
private function caseFilePath(string $type): string
{
$type = $this->assertSupportedType($type);
return sprintf('%s/tests/evals/cases/%s.ndjson', $this->projectDir, $type);
}
private function statusFromReport(?array $report): string private function statusFromReport(?array $report): string
{ {
if ($report === null) { if ($report === null) {

View File

@@ -212,6 +212,100 @@
</div> </div>
</div> </div>
<div class="card bg-black border-secondary text-light shadow-sm mb-4" id="adminEvalCaseCreator">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
<div>
<h5 class="text-warning mb-1">
<i class="bi bi-plus-square"></i> Eval-Case erstellen
</h5>
<div class="small text-secondary">
Speichert neue Regression-Cases direkt in <code>tests/evals/cases/&lt;type&gt;.ndjson</code>.
Aus Report-Ergebnissen kannst du Prompt, History, Query oder Dokument-IDs als Vorlage übernehmen.
</div>
</div>
</div>
<form method="post" action="{{ path('admin_evals_case_create') }}" class="row g-3">
<input type="hidden" name="_token" value="{{ csrf_token('admin_eval_case_create') }}">
<div class="col-md-4">
<label class="form-label">Eval-Typ</label>
<select name="type" class="form-select bg-dark text-light border-secondary js-admin-eval-create-type">
{% for type, label in types %}
<option value="{{ type }}" {% if type == selected_type %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-8">
<label class="form-label">Neue Case-ID</label>
<input type="text"
name="id"
class="form-control bg-dark text-light border-secondary js-admin-eval-create-id"
placeholder="z. B. retrieval_semantic_new_001"
autocomplete="off"
required>
<div class="form-text text-secondary">
Erlaubt: Buchstaben, Zahlen, Unterstrich, Bindestrich. IDs müssen eindeutig sein.
</div>
</div>
<div class="col-12">
<label class="form-label">Prompt</label>
<textarea name="prompt"
rows="2"
class="form-control bg-dark text-light border-secondary js-admin-eval-create-prompt"
placeholder="Testprompt, der abgesichert werden soll"
required></textarea>
</div>
<div class="col-lg-6">
<label class="form-label">Assert-JSON</label>
<textarea name="assert_json"
rows="8"
class="form-control bg-dark text-light border-secondary font-monospace small js-admin-eval-create-assert"
spellcheck="false">{
"min_results": 1
}</textarea>
<div class="form-text text-secondary">
Beispiel: <code>expected_query</code>, <code>must_include_one_of_document_ids</code>, <code>must_not_include_terms</code>.
</div>
</div>
<div class="col-lg-6">
<label class="form-label">Optional: History-JSON</label>
<textarea name="history_json"
rows="8"
class="form-control bg-dark text-light border-secondary font-monospace small js-admin-eval-create-history"
spellcheck="false"
placeholder='[{"prompt":"...","answer":"..."}]'></textarea>
<div class="form-text text-secondary">
Für Follow-up-Cases: Liste vorheriger Chat-Turns mit <code>prompt</code> und <code>answer</code>.
</div>
</div>
<div class="col-12">
<label class="form-label">Optional: Request Context Hint</label>
<textarea name="request_context_hint"
rows="2"
class="form-control bg-dark text-light border-secondary js-admin-eval-create-context"
placeholder="Nur nutzen, wenn ein Case explizit Zusatzkontext braucht."></textarea>
</div>
<div class="col-12 d-flex gap-2 flex-wrap">
<button type="submit" class="btn btn-outline-warning">
<i class="bi bi-save"></i> Case speichern
</button>
<button type="button" class="btn btn-outline-secondary js-admin-eval-create-clear">
Formular leeren
</button>
</div>
</form>
</div>
</div>
<div class="card bg-black border-secondary text-light shadow-sm"> <div class="card bg-black border-secondary text-light shadow-sm">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3"> <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
@@ -281,9 +375,49 @@
<span class="badge bg-danger">FAIL</span> <span class="badge bg-danger">FAIL</span>
{% endif %} {% endif %}
</td> </td>
<td> <td style="min-width: 260px;">
<code>{{ result.case_id|default('') }}</code> <code>{{ result.case_id|default('') }}</code>
<div class="small text-secondary">{{ result.type|default('') }}</div> <div class="small text-secondary mb-2">{{ result.type|default('') }}</div>
{% set casePrompt = result.prompt|default(result.details.prompt|default('')) %}
{% if casePrompt %}
<div class="small mb-2">
<span class="text-secondary">Prompt:</span><br>
<span class="text-light">{{ casePrompt }}</span>
</div>
{% endif %}
{% set historyRows = result.details.history|default([]) %}
{% if historyRows is not empty %}
<details class="small">
<summary class="text-info" style="cursor:pointer;">
Kontext / History anzeigen
</summary>
<div class="mt-2 ps-2 border-start border-secondary">
{% for turn in historyRows %}
<div class="mb-2">
<div class="text-secondary">Vorheriger Prompt:</div>
<div class="text-light">{{ turn.prompt|default('') }}</div>
{% if turn.answer_preview|default('') %}
<div class="text-secondary mt-1">Antwort-Auszug:</div>
<div class="text-secondary">{{ turn.answer_preview }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</details>
{% endif %}
<button type="button"
class="btn btn-sm btn-outline-warning mt-2 js-admin-eval-prefill-case"
data-result-type="{{ result.type|default(selected_type)|e('html_attr') }}"
data-result-prompt="{{ casePrompt|default('')|e('html_attr') }}"
data-result-history="{{ historyRows|default([])|json_encode|e('html_attr') }}"
data-result-query="{{ result.details.query|default('')|e('html_attr') }}"
data-result-individual-queries="{{ result.details.individual_queries|default([])|json_encode|e('html_attr') }}"
data-result-document-ids="{{ result.details.document_ids|default([])|json_encode|e('html_attr') }}">
Als neuen Case vorbereiten
</button>
</td> </td>
<td style="width: 120px;"> <td style="width: 120px;">
{{ result.duration_ms|default(0) }} ms {{ result.duration_ms|default(0) }} ms
@@ -461,6 +595,173 @@
}); });
} }
const creator = document.getElementById('adminEvalCaseCreator');
function parseJsonData(value, fallback) {
if (!value) {
return fallback;
}
try {
return JSON.parse(value);
} catch (error) {
return fallback;
}
}
function slugifyPrompt(prompt) {
const normalized = (prompt || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 44);
return normalized || 'case';
}
function buildAssertTemplate(type, query, individualQueries, documentIds) {
if ((type === 'shop_query' || type === 'followup') && individualQueries.length > 0) {
return {
expected_individual_queries: individualQueries,
expected_individual_queries_exact: true
};
}
if ((type === 'shop_query' || type === 'followup') && query) {
return {
expected_query: query
};
}
if ((type === 'retrieval' || type === 'answer_guard') && documentIds.length > 0) {
return {
min_results: 1,
must_include_one_of_document_ids: [documentIds[0]]
};
}
if (type === 'answer_guard') {
return {
max_results: 0
};
}
return {
min_results: 1
};
}
function normalizeHistoryForForm(historyRows) {
return historyRows
.map(function (turn) {
return {
prompt: (turn.prompt || 'Eval-Kontext').trim(),
answer: (turn.answer || turn.response || turn.answer_preview || '').trim()
};
})
.filter(function (turn) {
return turn.prompt !== '' || turn.answer !== '';
});
}
function fillCreatorFormFromResult(button) {
if (!creator) {
return;
}
const type = button.dataset.resultType || 'retrieval';
const prompt = button.dataset.resultPrompt || '';
const history = normalizeHistoryForForm(parseJsonData(button.dataset.resultHistory, []));
const query = button.dataset.resultQuery || '';
const individualQueries = parseJsonData(button.dataset.resultIndividualQueries, []);
const documentIds = parseJsonData(button.dataset.resultDocumentIds, []);
const now = new Date();
const suffix = String(now.getFullYear()).slice(2)
+ String(now.getMonth() + 1).padStart(2, '0')
+ String(now.getDate()).padStart(2, '0')
+ '_'
+ String(now.getHours()).padStart(2, '0')
+ String(now.getMinutes()).padStart(2, '0')
+ String(now.getSeconds()).padStart(2, '0');
const typeField = creator.querySelector('.js-admin-eval-create-type');
const idField = creator.querySelector('.js-admin-eval-create-id');
const promptField = creator.querySelector('.js-admin-eval-create-prompt');
const assertField = creator.querySelector('.js-admin-eval-create-assert');
const historyField = creator.querySelector('.js-admin-eval-create-history');
const contextField = creator.querySelector('.js-admin-eval-create-context');
if (typeField) {
typeField.value = type;
}
if (idField) {
idField.value = type + '_' + slugifyPrompt(prompt) + '_' + suffix;
}
if (promptField) {
promptField.value = prompt;
}
if (assertField) {
assertField.value = JSON.stringify(
buildAssertTemplate(type, query, individualQueries, documentIds),
null,
2
);
}
if (historyField) {
historyField.value = history.length > 0 ? JSON.stringify(history, null, 2) : '';
}
if (contextField) {
contextField.value = '';
}
creator.scrollIntoView({behavior: 'smooth', block: 'start'});
}
if (creator) {
creator.querySelectorAll('.js-admin-eval-create-clear').forEach(function (button) {
button.addEventListener('click', function () {
const idField = creator.querySelector('.js-admin-eval-create-id');
const promptField = creator.querySelector('.js-admin-eval-create-prompt');
const assertField = creator.querySelector('.js-admin-eval-create-assert');
const historyField = creator.querySelector('.js-admin-eval-create-history');
const contextField = creator.querySelector('.js-admin-eval-create-context');
if (idField) {
idField.value = '';
}
if (promptField) {
promptField.value = '';
}
if (assertField) {
assertField.value = '{\n "min_results": 1\n}';
}
if (historyField) {
historyField.value = '';
}
if (contextField) {
contextField.value = '';
}
});
});
}
document.querySelectorAll('.js-admin-eval-prefill-case').forEach(function (button) {
button.addEventListener('click', function () {
fillCreatorFormFromResult(button);
});
});
forms.forEach(function (form) { forms.forEach(function (form) {
syncCaseSelect(form); syncCaseSelect(form);