p101
This commit is contained in:
731
RETRIEX-EVAL-CASE-HOWTO.md
Normal file
731
RETRIEX-EVAL-CASE-HOWTO.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')) . '...';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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/<type>.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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user