diff --git a/RETRIEX-EVAL-CASE-HOWTO.md b/RETRIEX-EVAL-CASE-HOWTO.md new file mode 100644 index 0000000..7925e48 --- /dev/null +++ b/RETRIEX-EVAL-CASE-HOWTO.md @@ -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 +___ +``` + +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 +``` \ No newline at end of file diff --git a/patch_history/RETRIEX_PATCH_100D_ADMIN_EVAL_PROMPT_CONTEXT_README.md b/patch_history/RETRIEX_PATCH_100D_ADMIN_EVAL_PROMPT_CONTEXT_README.md new file mode 100644 index 0000000..8686803 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_100D_ADMIN_EVAL_PROMPT_CONTEXT_README.md @@ -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. diff --git a/patch_history/RETRIEX_PATCH_101_ADMIN_EVAL_CASE_CREATOR_README.md b/patch_history/RETRIEX_PATCH_101_ADMIN_EVAL_CASE_CREATOR_README.md new file mode 100644 index 0000000..637bc83 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_101_ADMIN_EVAL_CASE_CREATOR_README.md @@ -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. diff --git a/src/Controller/Admin/AdminEvalController.php b/src/Controller/Admin/AdminEvalController.php index db7d2e8..097d0ea 100644 --- a/src/Controller/Admin/AdminEvalController.php +++ b/src/Controller/Admin/AdminEvalController.php @@ -67,4 +67,45 @@ final class AdminEvalController extends AbstractController '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, + ]); + } + } diff --git a/src/Eval/ShopQueryEvalRunner.php b/src/Eval/ShopQueryEvalRunner.php index 4b137cd..f985fd3 100644 --- a/src/Eval/ShopQueryEvalRunner.php +++ b/src/Eval/ShopQueryEvalRunner.php @@ -74,6 +74,7 @@ final readonly class ShopQueryEvalRunner details: [ 'prompt' => $case->prompt, 'history_turns' => count($case->history), + 'history' => $this->buildHistoryPreview($case->history), 'has_request_context_hint' => $case->requestContextHint !== '', 'query' => $shopMeta['query'], 'individual_queries' => $shopMeta['individual_queries'], @@ -82,6 +83,31 @@ final readonly class ShopQueryEvalRunner ); } + /** + * @param array $history + * @return array + */ + 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 { $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)); } - private function previewText(string $value): string + private function previewText(string $value, int $maxLength = 1200): string { $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 rtrim(mb_substr($value, 0, 1200, 'UTF-8')) . '...'; + return rtrim(mb_substr($value, 0, $maxLength, 'UTF-8')) . '...'; } } diff --git a/src/Service/Admin/EvalAdminService.php b/src/Service/Admin/EvalAdminService.php index 008693a..57d4731 100644 --- a/src/Service/Admin/EvalAdminService.php +++ b/src/Service/Admin/EvalAdminService.php @@ -145,6 +145,83 @@ final readonly class EvalAdminService } + /** + * @return array{type:string,id:string,path:string,row:array,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 $cases * @return array @@ -249,6 +326,123 @@ final readonly class EvalAdminService 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 + */ + 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 + */ + 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 { if ($report === null) { diff --git a/templates/admin/evals/index.html.twig b/templates/admin/evals/index.html.twig index 9265281..d7c328d 100644 --- a/templates/admin/evals/index.html.twig +++ b/templates/admin/evals/index.html.twig @@ -212,6 +212,100 @@ + +
+
+
+
+
+ Eval-Case erstellen +
+
+ Speichert neue Regression-Cases direkt in tests/evals/cases/<type>.ndjson. + Aus Report-Ergebnissen kannst du Prompt, History, Query oder Dokument-IDs als Vorlage übernehmen. +
+
+
+ +
+ + +
+ + +
+ +
+ + +
+ Erlaubt: Buchstaben, Zahlen, Unterstrich, Bindestrich. IDs müssen eindeutig sein. +
+
+ +
+ + +
+ +
+ + +
+ Beispiel: expected_query, must_include_one_of_document_ids, must_not_include_terms. +
+
+ +
+ + +
+ Für Follow-up-Cases: Liste vorheriger Chat-Turns mit prompt und answer. +
+
+ +
+ + +
+ +
+ + +
+
+
+
+
@@ -281,9 +375,49 @@ FAIL {% endif %} - + {{ result.case_id|default('') }} -
{{ result.type|default('') }}
+
{{ result.type|default('') }}
+ + {% set casePrompt = result.prompt|default(result.details.prompt|default('')) %} + {% if casePrompt %} +
+ Prompt:
+ {{ casePrompt }} +
+ {% endif %} + + {% set historyRows = result.details.history|default([]) %} + {% if historyRows is not empty %} +
+ + Kontext / History anzeigen + +
+ {% for turn in historyRows %} +
+
Vorheriger Prompt:
+
{{ turn.prompt|default('') }}
+ {% if turn.answer_preview|default('') %} +
Antwort-Auszug:
+
{{ turn.answer_preview }}
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + {{ 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) { syncCaseSelect(form);