diff --git a/patch_history/RETRIEX_PATCH_101C_ADMIN_EVAL_CASE_DELETE_README.md b/patch_history/RETRIEX_PATCH_101C_ADMIN_EVAL_CASE_DELETE_README.md new file mode 100644 index 0000000..0c2e395 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_101C_ADMIN_EVAL_CASE_DELETE_README.md @@ -0,0 +1,50 @@ +# RetrieX Patch p101c - Admin Eval Case Delete + +## Ziel + +Ergänzt die Admin-Eval-Case-Verwaltung um eine sichere Löschfunktion für einzelne Eval-Cases. + +Damit können falsch angelegte oder nicht mehr benötigte Cases direkt im Admin entfernt werden, ohne die Eval-Suite-Übersicht weiter aufzublähen. + +## Umfang + +- Neue POST-Route `admin_evals_case_delete` unter `/admin/evals/cases/delete` +- CSRF-Schutz pro Eval-Typ und Case-ID +- Rollenprüfung über `ROLE_KNOWLEDGE_ADMIN` +- Entfernen genau des ausgewählten Cases aus `tests/evals/cases/.ndjson` +- Abbruch ohne Änderung, wenn die NDJSON-Datei ungültig ist oder der Case nicht gefunden wird +- Löschbereich auf der separaten Case-Seite `/admin/evals/cases/new` +- Bestätigungsdialog vor dem Löschen +- Hinweis, dass nach dem Löschen der betroffene Eval-Typ erneut ausgeführt werden sollte + +## Nicht geändert + +- Keine Retrieval-Logik +- Keine Shopquery-Logik +- Keine Follow-up-Logik +- Keine Answer-Guard-Logik +- Keine Eval-Assertions +- Keine bestehenden Cases automatisch gelöscht +- Keine YAML-/Parameteränderung +- Keine Migration + +## Prüfung + +Nach Einspielen: + +```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 +``` + +Im Admin: + +1. `/admin/evals/cases/new` öffnen. +2. Einen Test-Case anlegen oder einen bestehenden Test-Case auswählen. +3. `Case löschen` klicken. +4. Bestätigungsdialog bestätigen. +5. Prüfen, dass der Case aus der Liste verschwindet. +6. Den betroffenen Eval-Typ erneut laufen lassen. diff --git a/patch_history/RETRIEX_PATCH_101D_ADMIN_EVAL_CASE_DELETE_HOTFIX_README.md b/patch_history/RETRIEX_PATCH_101D_ADMIN_EVAL_CASE_DELETE_HOTFIX_README.md new file mode 100644 index 0000000..0e0575c --- /dev/null +++ b/patch_history/RETRIEX_PATCH_101D_ADMIN_EVAL_CASE_DELETE_HOTFIX_README.md @@ -0,0 +1,53 @@ +# RetrieX Patch p101d - Admin Eval Case Delete Hotfix + +## Ziel + +Behebt einen Fehler aus p101c, bei dem beim Löschen eines Eval-Cases folgende Exception auftreten konnte: + +```text +Call to undefined method App\Service\Admin\EvalAdminService::normalizeExistingCaseId() +``` + +## Ursache + +`EvalAdminService::deleteCase()` ruft eine Validierungs-Hilfsmethode für bestehende Case-IDs auf. Diese Methode wurde in p101c referenziert, aber nicht in die Service-Klasse aufgenommen. + +## Änderung + +Ergänzt `normalizeExistingCaseId()` in `EvalAdminService`. + +Die Methode: + +- trimmt die übergebene Case-ID, +- verhindert leere IDs, +- erlaubt nur Buchstaben, Zahlen, Unterstriche und Bindestriche, +- gibt eine verständliche Fehlermeldung bei ungültigen IDs zurück. + +## Geänderte Dateien + +```text +src/Service/Admin/EvalAdminService.php +patch_history/RETRIEX_PATCH_101D_ADMIN_EVAL_CASE_DELETE_HOTFIX_README.md +``` + +## Nicht geändert + +```text +keine Eval-Logik +keine Retrieval-Logik +keine Shopquery-Logik +keine Follow-up-Logik +keine Answer-Guard-Logik +keine YAML-/Parameteränderung +keine bestehenden Eval-Cases +keine Migration +``` + +## Prüfung + +```bash +php -l src/Service/Admin/EvalAdminService.php +php bin/console mto:agent:config:validate +``` + +Danach im Admin einen Eval-Case löschen. diff --git a/src/Controller/Admin/AdminEvalController.php b/src/Controller/Admin/AdminEvalController.php index 71d9d4d..340d897 100644 --- a/src/Controller/Admin/AdminEvalController.php +++ b/src/Controller/Admin/AdminEvalController.php @@ -92,6 +92,7 @@ final class AdminEvalController extends AbstractController return $this->render('admin/evals/case_new.html.twig', [ 'types' => $evals->supportedTypes(), + 'cases_by_type' => $evals->casesByType(), 'case_draft' => $draft, ]); } @@ -146,7 +147,46 @@ final class AdminEvalController extends AbstractController return $this->render('admin/evals/case_new.html.twig', [ 'types' => $evals->supportedTypes(), + 'cases_by_type' => $evals->casesByType(), 'case_draft' => $draft, ], new Response('', Response::HTTP_UNPROCESSABLE_ENTITY)); } + + + #[Route('/cases/delete', name: 'admin_evals_case_delete', methods: ['POST'])] + public function deleteCase(Request $request, EvalAdminService $evals): Response + { + $this->denyAccessUnlessGranted(ApplicationRoles::ROLE_KNOWLEDGE_ADMIN); + + $type = trim((string) $request->request->get('type', 'retrieval')); + $caseId = trim((string) $request->request->get('case_id', '')); + + if (!$this->isCsrfTokenValid( + sprintf('admin_eval_case_delete_%s_%s', $type, $caseId), + (string) $request->request->get('_token') + )) { + throw $this->createAccessDeniedException(); + } + + try { + $deleted = $evals->deleteCase($type, $caseId); + $type = (string) ($deleted['type'] ?? $type); + + $this->addFlash( + 'success', + sprintf('Eval-Case "%s" wurde aus %s.ndjson entfernt.', (string) ($deleted['id'] ?? $caseId), $type) + ); + } catch (\Throwable $e) { + $this->addFlash('danger', $e->getMessage()); + } + + if (!in_array($type, $evals->supportedTypeNames(), true)) { + $type = 'retrieval'; + } + + return $this->redirectToRoute('admin_evals_case_new', [ + 'type' => $type, + ]); + } + } diff --git a/src/Service/Admin/EvalAdminService.php b/src/Service/Admin/EvalAdminService.php index c91c2e2..939311b 100644 --- a/src/Service/Admin/EvalAdminService.php +++ b/src/Service/Admin/EvalAdminService.php @@ -290,6 +290,77 @@ final readonly class EvalAdminService ]; } + + /** + * @return array{type:string,id:string,path:string,case_count:int} + */ + public function deleteCase(string $type, string $caseId): array + { + $type = $this->assertSupportedType($type); + $caseId = $this->normalizeExistingCaseId($caseId); + $path = $this->caseFilePath($type); + + if (!is_file($path)) { + throw new \RuntimeException(sprintf('Eval-Case-Datei wurde nicht gefunden: %s', $path)); + } + + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + throw new \RuntimeException(sprintf('Eval-Case-Datei konnte nicht gelesen werden: %s', $path)); + } + + $keptLines = []; + $deleted = false; + + foreach ($lines as $line) { + $trimmed = trim((string) $line); + if ($trimmed === '') { + continue; + } + + try { + $decoded = json_decode($trimmed, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException(sprintf( + 'Eval-Case-Datei enthält ungültiges JSON und wurde nicht verändert: %s', + $e->getMessage() + )); + } + + if (!is_array($decoded)) { + throw new \RuntimeException('Eval-Case-Datei enthält eine ungültige NDJSON-Zeile und wurde nicht verändert.'); + } + + if ((string) ($decoded['id'] ?? '') === $caseId) { + $deleted = true; + continue; + } + + $keptLines[] = $trimmed; + } + + if (!$deleted) { + throw new \RuntimeException(sprintf( + 'Eval-Case "%s" wurde im Typ "%s" nicht gefunden.', + $caseId, + $type + )); + } + + $contents = $keptLines === [] ? '' : implode(PHP_EOL, $keptLines) . PHP_EOL; + $written = file_put_contents($path, $contents, LOCK_EX); + if ($written === false) { + throw new \RuntimeException(sprintf('Eval-Case-Datei konnte nicht geschrieben werden: %s', $path)); + } + + return [ + 'type' => $type, + 'id' => $caseId, + 'path' => $path, + 'case_count' => count($this->loadCases($type)), + ]; + } + /** * @param array $cases * @return array @@ -411,6 +482,23 @@ final readonly class EvalAdminService return $id; } + private function normalizeExistingCaseId(string $id): string + { + $id = trim($id); + + if ($id === '') { + throw new \InvalidArgumentException('Es wurde keine Eval-Case-ID zum Löschen übergeben.'); + } + + if (preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/', $id) !== 1) { + throw new \InvalidArgumentException( + 'Die Eval-Case-ID ist ungültig. Erlaubt sind nur Buchstaben, Zahlen, Unterstriche und Bindestriche.' + ); + } + + return $id; + } + private function caseIdExists(string $id): bool { foreach (array_keys(self::TYPES) as $type) { diff --git a/templates/admin/evals/case_new.html.twig b/templates/admin/evals/case_new.html.twig index 64f4e5a..5f31c70 100644 --- a/templates/admin/evals/case_new.html.twig +++ b/templates/admin/evals/case_new.html.twig @@ -1,16 +1,16 @@ {% extends 'admin/base.html.twig' %} -{% block title %}Eval-Case erstellen{% endblock %} +{% block title %}Eval-Cases verwalten{% endblock %} {% block body %}

- Eval-Case erstellen + Eval-Cases verwalten

- Neue Regression-Cases separat anlegen, ohne die Eval-Suite-Übersicht aufzublähen. + Neue Regression-Cases separat anlegen oder bestehende Cases entfernen, ohne die Eval-Suite-Übersicht aufzublähen.
@@ -203,6 +203,59 @@
+
+
+
+ Bestehende Eval-Cases entfernen +
+

+ Hier kannst du falsch angelegte oder nicht mehr benötigte Cases aus den + tests/evals/cases/*.ndjson-Dateien entfernen. Das Löschen betrifft nur den Eval-Case, + nicht das RAG-Wissen, nicht den Shop und nicht die bestehenden Reports. +

+ + {% for type, label in types %} + {% set cases = cases_by_type[type]|default([]) %} +
+ + {{ label }} ({{ cases|length }} Cases) + + + {% if cases is empty %} +
+ Für diesen Typ gibt es aktuell keine Cases. +
+ {% else %} +
+ {% for case in cases %} +
+
+ {{ case.id }} +
{{ case.prompt }}
+
+
+ + + + +
+
+ {% endfor %} +
+ {% endif %} +
+ {% endfor %} + +
+ Nach dem Löschen solltest du den betroffenen Eval-Typ einmal ausführen, damit der Report zum neuen Case-Bestand passt. +
+
+
+
diff --git a/templates/admin/evals/index.html.twig b/templates/admin/evals/index.html.twig index 9c6d033..f79c8e1 100644 --- a/templates/admin/evals/index.html.twig +++ b/templates/admin/evals/index.html.twig @@ -17,7 +17,7 @@