feat: Ergebnisdarstellung mit Herleitung (Schritt 4 der Baureihenfolge)
POST /antraege berechnet und speichert direkt beim Einreichen eine
append-only bewertung (Datenklasse, KI-VO-Einstufung, Anforderungen,
zulässige/ausgeschlossene Werkzeuge), GET /antraege/{id} zeigt sie
inklusive Herleitung und dem Pflicht-Hinweis, dass das System nicht
entscheidet, sondern nur vorbereitet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
23
CLAUDE.md
23
CLAUDE.md
@@ -147,10 +147,13 @@ und wertet sie seit Schritt 3 auch aus: `EvaluateDatenklasse`,
|
|||||||
`FilterWerkzeuge`/`ErfuelltAnforderung` filtern einen Werkzeugkatalog
|
`FilterWerkzeuge`/`ErfuelltAnforderung` filtern einen Werkzeugkatalog
|
||||||
hart gegen die abgeleiteten Anforderungen (`WerkzeugEigenschaften` ist
|
hart gegen die abgeleiteten Anforderungen (`WerkzeugEigenschaften` ist
|
||||||
ein schlanker, von `store.Werkzeug` unabhängiger Typ, damit
|
ein schlanker, von `store.Werkzeug` unabhängiger Typ, damit
|
||||||
`internal/rules` weiterhin ohne `internal/store` auskommt). **Noch
|
`internal/rules` weiterhin ohne `internal/store` auskommt). Seit
|
||||||
nicht verdrahtet:** eine Web-Seite, die das für einen konkreten Antrag
|
Schritt 4 wird das auch tatsächlich aufgerufen: `POST /antraege`
|
||||||
tatsächlich aufruft und anzeigt — das ist Schritt 4
|
berechnet nach dem Einreichen sofort eine `bewertung` (append-only,
|
||||||
("Ergebnisdarstellung mit Herleitung").
|
`internal/web/antrag_handlers.go:bewerteAntrag`) — bei `verboten`
|
||||||
|
(K.-o.-Prüfung) ohne Werkzeugsuche — und `GET /antraege/{id}` zeigt sie
|
||||||
|
inklusive Herleitung, RegelwerkVersion/KatalogVersion und dem
|
||||||
|
Pflicht-Hinweis "Das System entscheidet nicht. Es bereitet vor."
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -328,8 +331,16 @@ wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/
|
|||||||
3. ~~Ableitungen und harte Filter~~ — **erledigt** (`internal/rules`:
|
3. ~~Ableitungen und harte Filter~~ — **erledigt** (`internal/rules`:
|
||||||
`EvaluateDatenklasse`/`EvaluateEinstufung`/`DeriveAnforderungen`/
|
`EvaluateDatenklasse`/`EvaluateEinstufung`/`DeriveAnforderungen`/
|
||||||
`IstVerboten`/`FilterWerkzeuge`, vollständig getestet gegen die
|
`IstVerboten`/`FilterWerkzeuge`, vollständig getestet gegen die
|
||||||
echten `rules/*.yaml`-Dateien. Noch nicht ans Web angebunden.)
|
echten `rules/*.yaml`-Dateien.)
|
||||||
4. Ergebnisdarstellung mit Herleitung
|
4. ~~Ergebnisdarstellung mit Herleitung~~ — **erledigt** (`POST /antraege`
|
||||||
|
berechnet und speichert eine `bewertung` sofort beim Einreichen,
|
||||||
|
`GET /antraege/{id}` zeigt Datenklasse, KI-VO-Einstufung,
|
||||||
|
Anforderungen, zulässige/ausgeschlossene Werkzeuge — jeweils mit
|
||||||
|
Herleitung — sowie RegelwerkVersion/KatalogVersion und den
|
||||||
|
Pflicht-Hinweis "Das System entscheidet nicht. Es bereitet vor.".
|
||||||
|
`bewertung` ist append-only. Getestet inkl. K.-o.-Prüfung
|
||||||
|
(`verboten` überspringt die Werkzeugsuche) und hartem Filter
|
||||||
|
gegen den Katalog.)
|
||||||
5. Entscheidung, Snapshot, Audit-Log
|
5. Entscheidung, Snapshot, Audit-Log
|
||||||
6. Registereintrag und Export
|
6. Registereintrag und Export
|
||||||
7. Wiedervorlage und Katalog-Benachrichtigung
|
7. Wiedervorlage und Katalog-Benachrichtigung
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ func main() {
|
|||||||
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
|
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
|
||||||
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
|
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
|
||||||
|
|
||||||
server, err := web.NewServer(db)
|
server, err := web.NewServer(db, web.Regelwerk{
|
||||||
|
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("web server: %v", err)
|
log.Fatalf("web server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package rules
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +34,14 @@ func (a Antworten) istJaOderUnsicher(key string) bool {
|
|||||||
return v == "ja" || v == "unsicher"
|
return v == "ja" || v == "unsicher"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DatenklasseErgebnis ist die abgeleitete Datenklasse mit Herleitung —
|
||||||
|
// "Jede Anforderung/Ableitung trägt ihre Herleitung, sonst ist das
|
||||||
|
// Ergebnis im Audit wertlos" (siehe CLAUDE.md).
|
||||||
|
type DatenklasseErgebnis struct {
|
||||||
|
ID string
|
||||||
|
Herleitung string
|
||||||
|
}
|
||||||
|
|
||||||
// EvaluateDatenklasse leitet die Datenklasse aus den B-Antworten ab.
|
// EvaluateDatenklasse leitet die Datenklasse aus den B-Antworten ab.
|
||||||
// "Höchste zutreffende Stufe gewinnt" (höherer Rang gewinnt). Trifft
|
// "Höchste zutreffende Stufe gewinnt" (höherer Rang gewinnt). Trifft
|
||||||
// keine Stufe zu (z. B. wenn versehentlich auch B7 mit "nein"
|
// keine Stufe zu (z. B. wenn versehentlich auch B7 mit "nein"
|
||||||
@@ -40,24 +49,27 @@ func (a Antworten) istJaOderUnsicher(key string) bool {
|
|||||||
// eigentlich widersprüchlicher Fragebogen-Zustand), wird konservativ
|
// eigentlich widersprüchlicher Fragebogen-Zustand), wird konservativ
|
||||||
// "intern" angenommen statt "oeffentlich": im Zweifel mehr Schutz, nicht
|
// "intern" angenommen statt "oeffentlich": im Zweifel mehr Schutz, nicht
|
||||||
// weniger. Siehe rules/OPEN.md, Punkt 5.
|
// weniger. Siehe rules/OPEN.md, Punkt 5.
|
||||||
func EvaluateDatenklasse(regelwerk DatenklasseRegelwerk, antworten Antworten) string {
|
func EvaluateDatenklasse(regelwerk DatenklasseRegelwerk, antworten Antworten) DatenklasseErgebnis {
|
||||||
const fallback = "intern"
|
const fallback = "intern"
|
||||||
var gewinner *DatenklasseStufe
|
var gewinner *DatenklasseStufe
|
||||||
|
var treffer []string
|
||||||
for i := range regelwerk.Stufen {
|
for i := range regelwerk.Stufen {
|
||||||
st := ®elwerk.Stufen[i]
|
st := ®elwerk.Stufen[i]
|
||||||
|
var stTreffer []string
|
||||||
for _, ausloeser := range st.Ausloeser {
|
for _, ausloeser := range st.Ausloeser {
|
||||||
if antworten.istJaOderUnsicher(ausloeser) {
|
if antworten.istJaOderUnsicher(ausloeser) {
|
||||||
if gewinner == nil || st.Rang > gewinner.Rang {
|
stTreffer = append(stTreffer, ausloeser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stTreffer) > 0 && (gewinner == nil || st.Rang > gewinner.Rang) {
|
||||||
gewinner = st
|
gewinner = st
|
||||||
}
|
treffer = stTreffer
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if gewinner == nil {
|
if gewinner == nil {
|
||||||
return fallback
|
return DatenklasseErgebnis{ID: fallback, Herleitung: "keine Kategorie aus Abschnitt B traf eindeutig zu (konservativer Standardwert)"}
|
||||||
}
|
}
|
||||||
return gewinner.ID
|
return DatenklasseErgebnis{ID: gewinner.ID, Herleitung: "ausgelöst durch " + strings.Join(treffer, ", ")}
|
||||||
}
|
}
|
||||||
|
|
||||||
// variantePasst prüft, ob alle Schlüssel-Werte-Paare einer Variante zu
|
// variantePasst prüft, ob alle Schlüssel-Werte-Paare einer Variante zu
|
||||||
@@ -86,24 +98,46 @@ func variantePasst(variante EinstufungVariante, antworten Antworten) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EinstufungErgebnis ist die abgeleitete KI-VO-Einstufung mit Herleitung.
|
||||||
|
type EinstufungErgebnis struct {
|
||||||
|
ID string
|
||||||
|
Quelle string // z. B. "Art. 5 KI-VO", leer wenn die Stufe keine Quelle nennt
|
||||||
|
Herleitung string
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatVariante liefert eine deterministische, lesbare Darstellung
|
||||||
|
// einer zutreffenden Variante, z. B. "c2=true, c2_folge=kreditwuerdigkeit".
|
||||||
|
func formatVariante(v EinstufungVariante) string {
|
||||||
|
keys := make([]string, 0, len(v))
|
||||||
|
for k := range v {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%v", k, v[k]))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
// EvaluateEinstufung leitet die KI-VO-Einstufung aus den C-Antworten ab.
|
// EvaluateEinstufung leitet die KI-VO-Einstufung aus den C-Antworten ab.
|
||||||
// Prüfreihenfolge: die erste zutreffende Stufe gewinnt (siehe
|
// Prüfreihenfolge: die erste zutreffende Stufe gewinnt (siehe
|
||||||
// Bewertungslogik, K.-o.-Prüfung) — deshalb steht "verboten" in
|
// Bewertungslogik, K.-o.-Prüfung) — deshalb steht "verboten" in
|
||||||
// rules/kivo_einstufung.yaml an erster Stelle. Liefert einen Fehler nur,
|
// rules/kivo_einstufung.yaml an erster Stelle. Liefert einen Fehler nur,
|
||||||
// wenn das Regelwerk selbst keine Auffangregel definiert (sollte durch
|
// wenn das Regelwerk selbst keine Auffangregel definiert (sollte durch
|
||||||
// LoadEinstufung bereits verhindert sein).
|
// LoadEinstufung bereits verhindert sein).
|
||||||
func EvaluateEinstufung(regelwerk EinstufungRegelwerk, antworten Antworten) (string, error) {
|
func EvaluateEinstufung(regelwerk EinstufungRegelwerk, antworten Antworten) (EinstufungErgebnis, error) {
|
||||||
for _, st := range regelwerk.Stufen {
|
for _, st := range regelwerk.Stufen {
|
||||||
if len(st.Varianten) == 0 {
|
if len(st.Varianten) == 0 {
|
||||||
return st.ID, nil // Auffangregel, z. B. "minimal"
|
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: "Auffangregel (keine speziellere Stufe traf zu)"}, nil
|
||||||
}
|
}
|
||||||
for _, variante := range st.Varianten {
|
for _, variante := range st.Varianten {
|
||||||
if variantePasst(variante, antworten) {
|
if variantePasst(variante, antworten) {
|
||||||
return st.ID, nil
|
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: formatVariante(variante)}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("rules: keine einstufung trifft zu und keine auffangregel definiert")
|
return EinstufungErgebnis{}, fmt.Errorf("rules: keine einstufung trifft zu und keine auffangregel definiert")
|
||||||
}
|
}
|
||||||
|
|
||||||
// IstVerboten prüft die K.-o.-Bedingung (Art. 5 KI-VO): bei "verboten"
|
// IstVerboten prüft die K.-o.-Bedingung (Art. 5 KI-VO): bei "verboten"
|
||||||
@@ -112,13 +146,27 @@ func IstVerboten(einstufungID string) bool {
|
|||||||
return einstufungID == "verboten"
|
return einstufungID == "verboten"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AnforderungErgebnis ist eine abgeleitete Anforderung mit Herleitung.
|
||||||
|
type AnforderungErgebnis struct {
|
||||||
|
ID string
|
||||||
|
Beschreibung string
|
||||||
|
Herleitung string // z. B. "aus Datenklasse personenbezogen"
|
||||||
|
}
|
||||||
|
|
||||||
// DeriveAnforderungen leitet aus Datenklasse und Einstufung die Menge
|
// DeriveAnforderungen leitet aus Datenklasse und Einstufung die Menge
|
||||||
// der Anforderungen ab, die ein Werkzeug erfüllen muss.
|
// der Anforderungen ab, die ein Werkzeug erfüllen muss.
|
||||||
func DeriveAnforderungen(regelwerk AnforderungsRegelwerk, datenklasseID, einstufungID string) []string {
|
func DeriveAnforderungen(regelwerk AnforderungsRegelwerk, datenklasseID, einstufungID string) []AnforderungErgebnis {
|
||||||
var out []string
|
var out []AnforderungErgebnis
|
||||||
for _, a := range regelwerk.Anforderungen {
|
for _, a := range regelwerk.Anforderungen {
|
||||||
if containsString(a.AusDatenklassen, datenklasseID) || containsString(a.AusEinstufungen, einstufungID) {
|
var gruende []string
|
||||||
out = append(out, a.ID)
|
if containsString(a.AusDatenklassen, datenklasseID) {
|
||||||
|
gruende = append(gruende, "Datenklasse "+datenklasseID)
|
||||||
|
}
|
||||||
|
if containsString(a.AusEinstufungen, einstufungID) {
|
||||||
|
gruende = append(gruende, "Einstufung "+einstufungID)
|
||||||
|
}
|
||||||
|
if len(gruende) > 0 {
|
||||||
|
out = append(out, AnforderungErgebnis{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: "aus " + strings.Join(gruende, " und ")})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -200,8 +248,17 @@ func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string) (zu
|
|||||||
return zulaessig, ausgeschlossen
|
return zulaessig, ausgeschlossen
|
||||||
}
|
}
|
||||||
|
|
||||||
// String liefert eine lesbare Begründung, z. B. für die spätere
|
// String liefert eine lesbare Begründung, z. B. für die
|
||||||
// Ergebnisdarstellung (Schritt 4).
|
// Ergebnisdarstellung.
|
||||||
func (a AusschlussGrund) String() string {
|
func (a AusschlussGrund) String() string {
|
||||||
return fmt.Sprintf("%s: erfüllt nicht %s", a.WerkzeugID, strings.Join(a.NichtErfuellt, ", "))
|
return fmt.Sprintf("%s: erfüllt nicht %s", a.WerkzeugID, strings.Join(a.NichtErfuellt, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegelwerkVersion liefert eine reproduzierbare Kennung des geladenen
|
||||||
|
// Regelwerk-Stands — wird mit jeder Bewertung eingefroren, damit im
|
||||||
|
// Audit nachvollziehbar bleibt, mit welcher Regelwerk-Version ein
|
||||||
|
// Vorschlag erzeugt wurde (analog zu store.CurrentKatalogVersion für
|
||||||
|
// den Werkzeugkatalog).
|
||||||
|
func RegelwerkVersion(dk DatenklasseRegelwerk, ei EinstufungRegelwerk, an AnforderungsRegelwerk) string {
|
||||||
|
return fmt.Sprintf("dk%d.ei%d.an%d", dk.Version, ei.Version, an.Version)
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ func TestEvaluateDatenklasseOnlyOeffentlich(t *testing.T) {
|
|||||||
dk, _, _ := loadRealRegelwerke(t)
|
dk, _, _ := loadRealRegelwerke(t)
|
||||||
antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "ja"}
|
antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "ja"}
|
||||||
got := rules.EvaluateDatenklasse(dk, antworten)
|
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||||
if got != "oeffentlich" {
|
if got.ID != "oeffentlich" {
|
||||||
t.Fatalf("EvaluateDatenklasse = %q, want oeffentlich", got)
|
t.Fatalf("EvaluateDatenklasse.ID = %q, want oeffentlich", got.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +39,8 @@ func TestEvaluateDatenklasseHighestWins(t *testing.T) {
|
|||||||
// besondere_kategorie hat den hoeheren Rang und muss gewinnen.
|
// besondere_kategorie hat den hoeheren Rang und muss gewinnen.
|
||||||
antworten := rules.Antworten{"b1": "ja", "b2": "ja", "b7": "nein"}
|
antworten := rules.Antworten{"b1": "ja", "b2": "ja", "b7": "nein"}
|
||||||
got := rules.EvaluateDatenklasse(dk, antworten)
|
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||||
if got != "besondere_kategorie" {
|
if got.ID != "besondere_kategorie" {
|
||||||
t.Fatalf("EvaluateDatenklasse = %q, want besondere_kategorie", got)
|
t.Fatalf("EvaluateDatenklasse.ID = %q, want besondere_kategorie", got.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +48,8 @@ func TestEvaluateDatenklasseUnsicherZaehltWieJa(t *testing.T) {
|
|||||||
dk, _, _ := loadRealRegelwerke(t)
|
dk, _, _ := loadRealRegelwerke(t)
|
||||||
antworten := rules.Antworten{"b3": "unsicher"}
|
antworten := rules.Antworten{"b3": "unsicher"}
|
||||||
got := rules.EvaluateDatenklasse(dk, antworten)
|
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||||
if got != "berufsgeheimnis" {
|
if got.ID != "berufsgeheimnis" {
|
||||||
t.Fatalf("EvaluateDatenklasse = %q, want berufsgeheimnis (unsicher zaehlt wie ja)", got)
|
t.Fatalf("EvaluateDatenklasse.ID = %q, want berufsgeheimnis (unsicher zaehlt wie ja)", got.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,8 +57,8 @@ func TestEvaluateDatenklasseFallsBackToInternWhenNothingMatches(t *testing.T) {
|
|||||||
dk, _, _ := loadRealRegelwerke(t)
|
dk, _, _ := loadRealRegelwerke(t)
|
||||||
antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "nein"}
|
antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "nein"}
|
||||||
got := rules.EvaluateDatenklasse(dk, antworten)
|
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||||
if got != "intern" {
|
if got.ID != "intern" {
|
||||||
t.Fatalf("EvaluateDatenklasse = %q, want intern (konservativer Fallback)", got)
|
t.Fatalf("EvaluateDatenklasse.ID = %q, want intern (konservativer Fallback)", got.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +69,10 @@ func TestEvaluateEinstufungVerboten(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
}
|
}
|
||||||
if got != "verboten" {
|
if got.ID != "verboten" {
|
||||||
t.Fatalf("EvaluateEinstufung = %q, want verboten", got)
|
t.Fatalf("EvaluateEinstufung.ID = %q, want verboten", got.ID)
|
||||||
}
|
}
|
||||||
if !rules.IstVerboten(got) {
|
if !rules.IstVerboten(got.ID) {
|
||||||
t.Error("IstVerboten sollte true liefern")
|
t.Error("IstVerboten sollte true liefern")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,8 +84,8 @@ func TestEvaluateEinstufungHochrisiko(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
}
|
}
|
||||||
if got != "hochrisiko" {
|
if got.ID != "hochrisiko" {
|
||||||
t.Fatalf("EvaluateEinstufung = %q, want hochrisiko", got)
|
t.Fatalf("EvaluateEinstufung.ID = %q, want hochrisiko", got.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +96,8 @@ func TestEvaluateEinstufungTransparenzpflicht(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
}
|
}
|
||||||
if got != "transparenzpflicht" {
|
if got.ID != "transparenzpflicht" {
|
||||||
t.Fatalf("EvaluateEinstufung = %q, want transparenzpflicht", got)
|
t.Fatalf("EvaluateEinstufung.ID = %q, want transparenzpflicht", got.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +108,10 @@ func TestEvaluateEinstufungMinimalFallback(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
}
|
}
|
||||||
if got != "minimal" {
|
if got.ID != "minimal" {
|
||||||
t.Fatalf("EvaluateEinstufung = %q, want minimal", got)
|
t.Fatalf("EvaluateEinstufung.ID = %q, want minimal", got.ID)
|
||||||
}
|
}
|
||||||
if rules.IstVerboten(got) {
|
if rules.IstVerboten(got.ID) {
|
||||||
t.Error("IstVerboten sollte fuer minimal false liefern")
|
t.Error("IstVerboten sollte fuer minimal false liefern")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,8 +121,11 @@ func TestDeriveAnforderungenPersonenbezogen(t *testing.T) {
|
|||||||
got := rules.DeriveAnforderungen(an, "personenbezogen", "minimal")
|
got := rules.DeriveAnforderungen(an, "personenbezogen", "minimal")
|
||||||
want := map[string]bool{"avv_erforderlich": true, "kein_training_auf_eingabe": true, "loeschfrist_max_tage": true}
|
want := map[string]bool{"avv_erforderlich": true, "kein_training_auf_eingabe": true, "loeschfrist_max_tage": true}
|
||||||
gotSet := map[string]bool{}
|
gotSet := map[string]bool{}
|
||||||
for _, id := range got {
|
for _, a := range got {
|
||||||
gotSet[id] = true
|
gotSet[a.ID] = true
|
||||||
|
if a.Herleitung == "" {
|
||||||
|
t.Errorf("Anforderung %q hat keine Herleitung", a.ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for id := range want {
|
for id := range want {
|
||||||
if !gotSet[id] {
|
if !gotSet[id] {
|
||||||
@@ -138,8 +141,8 @@ func TestDeriveAnforderungenHochrisikoAddsAufsichtUndDsfa(t *testing.T) {
|
|||||||
_, _, an := loadRealRegelwerke(t)
|
_, _, an := loadRealRegelwerke(t)
|
||||||
got := rules.DeriveAnforderungen(an, "oeffentlich", "hochrisiko")
|
got := rules.DeriveAnforderungen(an, "oeffentlich", "hochrisiko")
|
||||||
gotSet := map[string]bool{}
|
gotSet := map[string]bool{}
|
||||||
for _, id := range got {
|
for _, a := range got {
|
||||||
gotSet[id] = true
|
gotSet[a.ID] = true
|
||||||
}
|
}
|
||||||
if !gotSet["menschliche_aufsicht"] || !gotSet["dsfa_erforderlich"] {
|
if !gotSet["menschliche_aufsicht"] || !gotSet["dsfa_erforderlich"] {
|
||||||
t.Errorf("expected menschliche_aufsicht und dsfa_erforderlich fuer hochrisiko, got %v", got)
|
t.Errorf("expected menschliche_aufsicht und dsfa_erforderlich fuer hochrisiko, got %v", got)
|
||||||
|
|||||||
139
internal/store/bewertung.go
Normal file
139
internal/store/bewertung.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BewertungAnforderung ist eine abgeleitete Anforderung mit Herleitung,
|
||||||
|
// wie sie in bewertung.anforderungen (JSONB) gespeichert wird.
|
||||||
|
type BewertungAnforderung struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Beschreibung string `json:"beschreibung"`
|
||||||
|
Herleitung string `json:"herleitung"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BewertungAusschluss hält fest, warum ein Werkzeug für eine Bewertung
|
||||||
|
// ausgeschlossen wurde.
|
||||||
|
type BewertungAusschluss struct {
|
||||||
|
WerkzeugID string `json:"werkzeug_id"`
|
||||||
|
NichtErfuellt []string `json:"nicht_erfuellt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bewertung ist der berechnete Vorschlag für einen Antrag. Append-only
|
||||||
|
// — siehe Migration.
|
||||||
|
type Bewertung struct {
|
||||||
|
ID string
|
||||||
|
AntragID string
|
||||||
|
Datenklasse string
|
||||||
|
DatenklasseHerleitung string
|
||||||
|
Einstufung string
|
||||||
|
EinstufungHerleitung string
|
||||||
|
Verboten bool
|
||||||
|
Anforderungen []BewertungAnforderung
|
||||||
|
ZulaessigeWerkzeuge []string
|
||||||
|
AusgeschlosseneWerkzeuge []BewertungAusschluss
|
||||||
|
RegelwerkVersion string
|
||||||
|
KatalogVersion string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const bewertungColumns = `id, antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||||
|
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version, created_at`
|
||||||
|
|
||||||
|
func scanBewertung(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Bewertung, error) {
|
||||||
|
var b Bewertung
|
||||||
|
var anforderungenRaw, ausschlussRaw []byte
|
||||||
|
err := row.Scan(
|
||||||
|
&b.ID, &b.AntragID, &b.Datenklasse, &b.DatenklasseHerleitung, &b.Einstufung, &b.EinstufungHerleitung,
|
||||||
|
&b.Verboten, &anforderungenRaw, &b.ZulaessigeWerkzeuge, &ausschlussRaw, &b.RegelwerkVersion, &b.KatalogVersion, &b.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(anforderungenRaw, &b.Anforderungen); err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: anforderungen unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(ausschlussRaw, &b.AusgeschlosseneWerkzeuge); err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BewertungInput bündelt die Felder einer neuen Bewertung — bei elf
|
||||||
|
// Werten lesbarer als eine positionale Parameterliste (gleiches Muster
|
||||||
|
// wie WerkzeugInput).
|
||||||
|
type BewertungInput struct {
|
||||||
|
AntragID string
|
||||||
|
Datenklasse string
|
||||||
|
DatenklasseHerleitung string
|
||||||
|
Einstufung string
|
||||||
|
EinstufungHerleitung string
|
||||||
|
Verboten bool
|
||||||
|
Anforderungen []BewertungAnforderung
|
||||||
|
ZulaessigeWerkzeuge []string
|
||||||
|
AusgeschlosseneWerkzeuge []BewertungAusschluss
|
||||||
|
RegelwerkVersion string
|
||||||
|
KatalogVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBewertung speichert eine berechnete Bewertung für einen Antrag.
|
||||||
|
func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertung, error) {
|
||||||
|
if in.ZulaessigeWerkzeuge == nil {
|
||||||
|
in.ZulaessigeWerkzeuge = []string{}
|
||||||
|
}
|
||||||
|
if in.Anforderungen == nil {
|
||||||
|
in.Anforderungen = []BewertungAnforderung{}
|
||||||
|
}
|
||||||
|
if in.AusgeschlosseneWerkzeuge == nil {
|
||||||
|
in.AusgeschlosseneWerkzeuge = []BewertungAusschluss{}
|
||||||
|
}
|
||||||
|
anforderungenJSON, err := json.Marshal(in.Anforderungen)
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: anforderungen marshal: %w", err)
|
||||||
|
}
|
||||||
|
ausschlussJSON, err := json.Marshal(in.AusgeschlosseneWerkzeuge)
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO bewertung (
|
||||||
|
antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||||
|
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING `+bewertungColumns,
|
||||||
|
in.AntragID, in.Datenklasse, in.DatenklasseHerleitung, in.Einstufung, in.EinstufungHerleitung,
|
||||||
|
in.Verboten, anforderungenJSON, in.ZulaessigeWerkzeuge, ausschlussJSON, in.RegelwerkVersion, in.KatalogVersion,
|
||||||
|
)
|
||||||
|
b, err := scanBewertung(row)
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: create bewertung: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestBewertungForAntrag liefert die zuletzt berechnete Bewertung
|
||||||
|
// eines Antrags. Liefert ErrNotFound, wenn noch keine Bewertung
|
||||||
|
// existiert.
|
||||||
|
func (s *Store) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (Bewertung, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT `+bewertungColumns+`
|
||||||
|
FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||||
|
`, antragID)
|
||||||
|
b, err := scanBewertung(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Bewertung{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: get latest bewertung: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
126
internal/store/bewertung_test.go
Normal file
126
internal/store/bewertung_test.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testBewertungInput(antragID string) store.BewertungInput {
|
||||||
|
return store.BewertungInput{
|
||||||
|
AntragID: antragID, Datenklasse: "personenbezogen", DatenklasseHerleitung: "ausgelöst durch b1",
|
||||||
|
Einstufung: "minimal", EinstufungHerleitung: "Auffangregel", Verboten: false,
|
||||||
|
Anforderungen: []store.BewertungAnforderung{
|
||||||
|
{ID: "avv_erforderlich", Beschreibung: "AVV nötig", Herleitung: "aus Datenklasse personenbezogen"},
|
||||||
|
},
|
||||||
|
ZulaessigeWerkzeuge: []string{"werkzeug-a"},
|
||||||
|
AusgeschlosseneWerkzeuge: []store.BewertungAusschluss{
|
||||||
|
{WerkzeugID: "werkzeug-b", NichtErfuellt: []string{"avv_erforderlich"}},
|
||||||
|
},
|
||||||
|
RegelwerkVersion: "dk1.ei1.an1", KatalogVersion: "3-1735300000",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBewertungCreateAndGetLatest(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Testantrag")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBewertung: %v", err)
|
||||||
|
}
|
||||||
|
if b.Datenklasse != "personenbezogen" || len(b.Anforderungen) != 1 {
|
||||||
|
t.Fatalf("CreateBewertung = %+v, unerwartete Werte", b)
|
||||||
|
}
|
||||||
|
if b.Anforderungen[0].ID != "avv_erforderlich" {
|
||||||
|
t.Fatalf("Anforderungen[0] = %+v, unerwartet", b.Anforderungen[0])
|
||||||
|
}
|
||||||
|
if len(b.AusgeschlosseneWerkzeuge) != 1 || b.AusgeschlosseneWerkzeuge[0].WerkzeugID != "werkzeug-b" {
|
||||||
|
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, unerwartet", b.AusgeschlosseneWerkzeuge)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != b.ID {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag returned a different row than CreateBewertung")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLatestBewertungForAntragNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Ohne Bewertung")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBewertungReturnsNewestWhenMultiple(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Mehrfach bewertet")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID)); err != nil {
|
||||||
|
t.Fatalf("CreateBewertung (1): %v", err)
|
||||||
|
}
|
||||||
|
second := testBewertungInput(antrag.ID)
|
||||||
|
second.Datenklasse = "besondere_kategorie"
|
||||||
|
newest, err := s.CreateBewertung(ctx, second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBewertung (2): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != newest.ID || got.Datenklasse != "besondere_kategorie" {
|
||||||
|
t.Fatalf("expected the newest bewertung, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBewertungIsAppendOnly(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Append-only Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBewertung: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.Pool.Exec(ctx, `UPDATE bewertung SET datenklasse = 'geaendert' WHERE id = $1`, b.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected UPDATE on bewertung to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
_, err = s.Pool.Exec(ctx, `DELETE FROM bewertung WHERE id = $1`, b.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected DELETE on bewertung to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
}
|
||||||
1
internal/store/migrations/0009_bewertung.down.sql
Normal file
1
internal/store/migrations/0009_bewertung.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE bewertung;
|
||||||
26
internal/store/migrations/0009_bewertung.up.sql
Normal file
26
internal/store/migrations/0009_bewertung.up.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- Bewertung: der berechnete Vorschlag für einen Antrag (Datenklasse,
|
||||||
|
-- KI-VO-Einstufung, Anforderungsprofil, zulässige/ausgeschlossene
|
||||||
|
-- Werkzeuge mit Herleitung). Append-only wie finding/extraction im
|
||||||
|
-- alten Produkt — eine neue Bewertung (z. B. nach Änderung der
|
||||||
|
-- Antworten oder des Katalogs) ergänzt die alte, ersetzt sie nicht;
|
||||||
|
-- die Historie bleibt für den Audit erhalten. regelwerk_version und
|
||||||
|
-- katalog_version werden zum Berechnungszeitpunkt eingefroren.
|
||||||
|
CREATE TABLE bewertung (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||||
|
datenklasse TEXT NOT NULL,
|
||||||
|
datenklasse_herleitung TEXT NOT NULL,
|
||||||
|
einstufung TEXT NOT NULL,
|
||||||
|
einstufung_herleitung TEXT NOT NULL,
|
||||||
|
verboten BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
anforderungen JSONB NOT NULL DEFAULT '[]',
|
||||||
|
zulaessige_werkzeuge TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
ausgeschlossene_werkzeuge JSONB NOT NULL DEFAULT '[]',
|
||||||
|
regelwerk_version TEXT NOT NULL,
|
||||||
|
katalog_version TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER bewertung_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON bewertung
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,12 +120,14 @@ func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
user := currentUser(r)
|
user := currentUser(r)
|
||||||
|
|
||||||
|
antworten := antwortenFromForm(r)
|
||||||
|
|
||||||
antrag, err := s.store.CreateAntrag(ctx, user.AccountID, user.ID, abteilungID, titel)
|
antrag, err := s.store.CreateAntrag(ctx, user.AccountID, user.ID, abteilungID, titel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Antrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Antrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := s.store.UpdateAntragFelder(ctx, antrag.ID, titel, beschreibung, ergebnis, haeufigkeit, antwortenFromForm(r)); err != nil {
|
if _, err := s.store.UpdateAntragFelder(ctx, antrag.ID, titel, beschreibung, ergebnis, haeufigkeit, antworten); err != nil {
|
||||||
http.Error(w, "Antrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Antrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -131,10 +135,83 @@ func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Antrag konnte nicht eingereicht werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Antrag konnte nicht eingereicht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := s.bewerteAntrag(ctx, antrag.ID, user.AccountID, antworten); err != nil {
|
||||||
|
http.Error(w, "Bewertung konnte nicht berechnet werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/antraege/"+antrag.ID, http.StatusSeeOther)
|
http.Redirect(w, r, "/antraege/"+antrag.ID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bewerteAntrag berechnet die Bewertung für einen eingereichten Antrag
|
||||||
|
// (Schritt 3: Ableitungen und harte Filter) und speichert sie. Bei
|
||||||
|
// "verboten" erfolgt die K.-o.-Prüfung — keine Werkzeugsuche, siehe
|
||||||
|
// CLAUDE.md, Bewertungslogik.
|
||||||
|
func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string, antwortenJSON []byte) error {
|
||||||
|
antworten, err := rules.ParseAntworten(antwortenJSON)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
datenklasse := rules.EvaluateDatenklasse(s.regelwerk.Datenklasse, antworten)
|
||||||
|
einstufung, err := rules.EvaluateEinstufung(s.regelwerk.Einstufung, antworten)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
verboten := rules.IstVerboten(einstufung.ID)
|
||||||
|
|
||||||
|
in := store.BewertungInput{
|
||||||
|
AntragID: antragID, Datenklasse: datenklasse.ID, DatenklasseHerleitung: datenklasse.Herleitung,
|
||||||
|
Einstufung: einstufung.ID, EinstufungHerleitung: einstufungHerleitungText(einstufung), Verboten: verboten,
|
||||||
|
KatalogVersion: "n/a (K.-o.-Prüfung, keine Werkzeugsuche)",
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verboten {
|
||||||
|
anforderungen := rules.DeriveAnforderungen(s.regelwerk.Anforderungen, datenklasse.ID, einstufung.ID)
|
||||||
|
anforderungIDs := make([]string, len(anforderungen))
|
||||||
|
for i, a := range anforderungen {
|
||||||
|
in.Anforderungen = append(in.Anforderungen, store.BewertungAnforderung{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||||
|
anforderungIDs[i] = a.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
werkzeuge, err := s.store.ListWerkzeugeForAccount(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
||||||
|
for i, w := range werkzeuge {
|
||||||
|
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
||||||
|
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
|
||||||
|
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
||||||
|
in.ZulaessigeWerkzeuge = zulaessig
|
||||||
|
for _, a := range ausgeschlossen {
|
||||||
|
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
|
||||||
|
}
|
||||||
|
|
||||||
|
katalogVersion, err := s.store.CurrentKatalogVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.KatalogVersion = katalogVersion
|
||||||
|
}
|
||||||
|
in.RegelwerkVersion = rules.RegelwerkVersion(s.regelwerk.Datenklasse, s.regelwerk.Einstufung, s.regelwerk.Anforderungen)
|
||||||
|
|
||||||
|
_, err = s.store.CreateBewertung(ctx, in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// einstufungHerleitungText fügt die Quelle (z. B. "Art. 5 KI-VO") mit
|
||||||
|
// an, falls das Regelwerk eine nennt.
|
||||||
|
func einstufungHerleitungText(e rules.EinstufungErgebnis) string {
|
||||||
|
if e.Quelle == "" {
|
||||||
|
return e.Herleitung
|
||||||
|
}
|
||||||
|
return e.Herleitung + " (" + e.Quelle + ")"
|
||||||
|
}
|
||||||
|
|
||||||
type antragListItem struct {
|
type antragListItem struct {
|
||||||
ID string
|
ID string
|
||||||
Titel string
|
Titel string
|
||||||
@@ -173,6 +250,31 @@ func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type anforderungView struct {
|
||||||
|
ID string
|
||||||
|
Beschreibung string
|
||||||
|
Herleitung string
|
||||||
|
}
|
||||||
|
|
||||||
|
type werkzeugAusschlussView struct {
|
||||||
|
Name string
|
||||||
|
NichtErfuellt []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type bewertungView struct {
|
||||||
|
Datenklasse string
|
||||||
|
DatenklasseHerleitung string
|
||||||
|
Einstufung string
|
||||||
|
EinstufungHerleitung string
|
||||||
|
Verboten bool
|
||||||
|
Anforderungen []anforderungView
|
||||||
|
ZulaessigeWerkzeuge []string // Namen, nicht IDs
|
||||||
|
AusgeschlosseneWerkzeuge []werkzeugAusschlussView
|
||||||
|
RegelwerkVersion string
|
||||||
|
KatalogVersion string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
type antragDetailData struct {
|
type antragDetailData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
@@ -184,6 +286,7 @@ type antragDetailData struct {
|
|||||||
Status string
|
Status string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
Antworten map[string]any
|
Antworten map[string]any
|
||||||
|
Bewertung *bewertungView
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAntragDetail zeigt einen einzelnen Antrag mit allen Antworten.
|
// handleAntragDetail zeigt einen einzelnen Antrag mit allen Antworten.
|
||||||
@@ -207,7 +310,46 @@ func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
||||||
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antworten,
|
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antworten,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err == nil {
|
||||||
|
data.Bewertung = s.toBewertungView(r.Context(), bewertung)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "antrag-detail", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "antrag-detail", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toBewertungView löst die in der Bewertung nur als ID gespeicherten
|
||||||
|
// Werkzeuge zu Anzeigenamen auf. Ein Werkzeug, das seit der Berechnung
|
||||||
|
// gelöscht wurde, wird mit seiner ID statt eines Namens angezeigt,
|
||||||
|
// statt den ganzen Antrag unanzeigbar zu machen.
|
||||||
|
func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewertungView {
|
||||||
|
v := &bewertungView{
|
||||||
|
Datenklasse: b.Datenklasse, DatenklasseHerleitung: b.DatenklasseHerleitung,
|
||||||
|
Einstufung: b.Einstufung, EinstufungHerleitung: b.EinstufungHerleitung, Verboten: b.Verboten,
|
||||||
|
RegelwerkVersion: b.RegelwerkVersion, KatalogVersion: b.KatalogVersion,
|
||||||
|
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
}
|
||||||
|
for _, a := range b.Anforderungen {
|
||||||
|
v.Anforderungen = append(v.Anforderungen, anforderungView{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||||
|
}
|
||||||
|
for _, id := range b.ZulaessigeWerkzeuge {
|
||||||
|
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))
|
||||||
|
}
|
||||||
|
for _, a := range b.AusgeschlosseneWerkzeuge {
|
||||||
|
v.AusgeschlosseneWerkzeuge = append(v.AusgeschlosseneWerkzeuge, werkzeugAusschlussView{
|
||||||
|
Name: s.werkzeugName(ctx, a.WerkzeugID), NichtErfuellt: a.NichtErfuellt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) werkzeugName(ctx context.Context, id string) string {
|
||||||
|
w, err := s.store.GetWerkzeug(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return w.Name
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func fullAntragForm() url.Values {
|
func fullAntragForm() url.Values {
|
||||||
@@ -122,6 +124,123 @@ func TestAntragDetailRejectsForeignUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fullAntragForm liefert b1=ja (Datenklasse personenbezogen) und
|
||||||
|
// c1..c3=nein (keine der verboten/hochrisiko/transparenzpflicht-
|
||||||
|
// Varianten trifft zu) — die Einstufung fällt damit auf die
|
||||||
|
// Auffangregel "minimal" zurück (siehe rules/kivo_einstufung.yaml).
|
||||||
|
func TestAntragCreateComputesBewertung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if b.Datenklasse != "personenbezogen" {
|
||||||
|
t.Errorf("Datenklasse = %q, want personenbezogen", b.Datenklasse)
|
||||||
|
}
|
||||||
|
if b.Einstufung != "minimal" {
|
||||||
|
t.Errorf("Einstufung = %q, want minimal", b.Einstufung)
|
||||||
|
}
|
||||||
|
if b.Verboten {
|
||||||
|
t.Error("Verboten = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
|
||||||
|
if !strings.Contains(detail.Body.String(), "Das System entscheidet nicht") {
|
||||||
|
t.Errorf("expected the mandatory disclaimer on the detail page, got: %s", detail.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAntragCreateVerbotenSkipsWerkzeugSearch prüft die K.-o.-Prüfung:
|
||||||
|
// c3=ja mit c3_art=social_scoring löst "verboten" aus (Art. 5 KI-VO) —
|
||||||
|
// dann darf keine Werkzeugsuche stattfinden (siehe CLAUDE.md,
|
||||||
|
// Bewertungslogik).
|
||||||
|
func TestAntragCreateVerbotenSkipsWerkzeugSearch(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||||
|
|
||||||
|
form := fullAntragForm()
|
||||||
|
form.Set("c3", "ja")
|
||||||
|
form.Set("c3_art", "social_scoring")
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", form)
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if !b.Verboten || b.Einstufung != "verboten" {
|
||||||
|
t.Fatalf("Bewertung = %+v, want Verboten=true, Einstufung=verboten", b)
|
||||||
|
}
|
||||||
|
if len(b.ZulaessigeWerkzeuge) != 0 || len(b.AusgeschlosseneWerkzeuge) != 0 {
|
||||||
|
t.Errorf("expected no werkzeug search for a verboten Bewertung, got %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAntragCreateFiltersWerkzeugeByAnforderungen prüft den harten
|
||||||
|
// Filter: personenbezogen verlangt avv_erforderlich — ein Werkzeug ohne
|
||||||
|
// AVV wird ausgeschlossen und im Ergebnis mit Begründung genannt statt
|
||||||
|
// stillschweigend wegzufallen, ein Werkzeug mit AVV bleibt zulässig.
|
||||||
|
func TestAntragCreateFiltersWerkzeugeByAnforderungen(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||||
|
|
||||||
|
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{
|
||||||
|
ID: "werkzeug-ok", Name: "ChatGPT Enterprise",
|
||||||
|
AVVVerfuegbar: true, TrainingStandard: true,
|
||||||
|
}
|
||||||
|
fs.werkzeuge["werkzeug-ohne-avv"] = store.Werkzeug{
|
||||||
|
ID: "werkzeug-ohne-avv", Name: "Freies KI-Tool",
|
||||||
|
AVVVerfuegbar: false, TrainingStandard: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if len(b.ZulaessigeWerkzeuge) != 1 || b.ZulaessigeWerkzeuge[0] != "werkzeug-ok" {
|
||||||
|
t.Errorf("ZulaessigeWerkzeuge = %v, want [werkzeug-ok]", b.ZulaessigeWerkzeuge)
|
||||||
|
}
|
||||||
|
if len(b.AusgeschlosseneWerkzeuge) != 1 || b.AusgeschlosseneWerkzeuge[0].WerkzeugID != "werkzeug-ohne-avv" {
|
||||||
|
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, want [werkzeug-ohne-avv]", b.AusgeschlosseneWerkzeuge)
|
||||||
|
}
|
||||||
|
if got := b.AusgeschlosseneWerkzeuge[0].NichtErfuellt; len(got) != 1 || got[0] != "avv_erforderlich" {
|
||||||
|
t.Errorf("NichtErfuellt = %v, want [avv_erforderlich]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
|
||||||
|
body := detail.Body.String()
|
||||||
|
if !strings.Contains(body, "ChatGPT Enterprise") || !strings.Contains(body, "Freies KI-Tool") {
|
||||||
|
t.Errorf("expected both werkzeuge (by name) on the detail page, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
|
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
s := newServer(t, fs)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,17 +47,35 @@ type Store interface {
|
|||||||
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
|
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
|
||||||
SetAntragStatus(ctx context.Context, id, status string) error
|
SetAntragStatus(ctx context.Context, id, status string) error
|
||||||
ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error)
|
ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error)
|
||||||
|
|
||||||
|
ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
|
||||||
|
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
|
||||||
|
CurrentKatalogVersion(ctx context.Context) (string, error)
|
||||||
|
CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error)
|
||||||
|
GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
|
||||||
|
// Bewertung eines Antrags braucht (siehe internal/rules). Ein eigener
|
||||||
|
// Typ statt drei einzelne Server-Felder, damit main.go sie an einer
|
||||||
|
// Stelle zusammenstellt.
|
||||||
|
type Regelwerk struct {
|
||||||
|
Datenklasse rules.DatenklasseRegelwerk
|
||||||
|
Einstufung rules.EinstufungRegelwerk
|
||||||
|
Anforderungen rules.AnforderungsRegelwerk
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
|
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
store Store
|
store Store
|
||||||
|
regelwerk Regelwerk
|
||||||
templates *template.Template
|
templates *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer erstellt den Server.
|
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
|
||||||
func NewServer(st Store) (*Server, error) {
|
// wird einmal beim Start geladen, nicht pro Request.
|
||||||
|
func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||||
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
||||||
@@ -64,6 +83,7 @@ func NewServer(st Store) (*Server, error) {
|
|||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
store: st,
|
store: st,
|
||||||
|
regelwerk: regelwerk,
|
||||||
templates: tmpl,
|
templates: tmpl,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/auth"
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
"github.com/netcell-it/deklarix/internal/web"
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
)
|
)
|
||||||
@@ -31,6 +33,8 @@ type fakeStore struct {
|
|||||||
auditLog []store.AuditEntry
|
auditLog []store.AuditEntry
|
||||||
abteilungen map[string][]store.Abteilung // accountID -> Abteilungen
|
abteilungen map[string][]store.Abteilung // accountID -> Abteilungen
|
||||||
antraege map[string]store.Antrag
|
antraege map[string]store.Antrag
|
||||||
|
werkzeuge map[string]store.Werkzeug
|
||||||
|
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeStore() *fakeStore {
|
func newFakeStore() *fakeStore {
|
||||||
@@ -41,6 +45,8 @@ func newFakeStore() *fakeStore {
|
|||||||
sessions: map[string]store.Session{},
|
sessions: map[string]store.Session{},
|
||||||
abteilungen: map[string][]store.Abteilung{},
|
abteilungen: map[string][]store.Abteilung{},
|
||||||
antraege: map[string]store.Antrag{},
|
antraege: map[string]store.Antrag{},
|
||||||
|
werkzeuge: map[string]store.Werkzeug{},
|
||||||
|
bewertungen: map[string][]store.Bewertung{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,11 +243,87 @@ func (f *fakeStore) ListAntraegeForUser(ctx context.Context, erstellerUserID str
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Werkzeug
|
||||||
|
for _, w := range f.werkzeuge {
|
||||||
|
if w.AccountID == nil || *w.AccountID == accountID {
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
w, ok := f.werkzeuge[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Werkzeug{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return fmt.Sprintf("fake-%d", len(f.werkzeuge)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
b := store.Bewertung{
|
||||||
|
ID: f.newID(), AntragID: in.AntragID,
|
||||||
|
Datenklasse: in.Datenklasse, DatenklasseHerleitung: in.DatenklasseHerleitung,
|
||||||
|
Einstufung: in.Einstufung, EinstufungHerleitung: in.EinstufungHerleitung,
|
||||||
|
Verboten: in.Verboten, Anforderungen: in.Anforderungen,
|
||||||
|
ZulaessigeWerkzeuge: in.ZulaessigeWerkzeuge, AusgeschlosseneWerkzeuge: in.AusgeschlosseneWerkzeuge,
|
||||||
|
RegelwerkVersion: in.RegelwerkVersion, KatalogVersion: in.KatalogVersion,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.bewertungen[in.AntragID] = append(f.bewertungen[in.AntragID], b)
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
bs := f.bewertungen[antragID]
|
||||||
|
if len(bs) == 0 {
|
||||||
|
return store.Bewertung{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return bs[len(bs)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Test-Setup ───────────────────────────────────────────────────────
|
// ─── Test-Setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
|
||||||
|
// die auch main.go beim Start lädt. Handler-Tests laufen so gegen das
|
||||||
|
// tatsächliche Regelwerk statt gegen ein Test-Fixture, das getrennt
|
||||||
|
// von rules/ gepflegt werden müsste.
|
||||||
|
func loadTestRegelwerk(t *testing.T) web.Regelwerk {
|
||||||
|
t.Helper()
|
||||||
|
fsys := os.DirFS("../../rules")
|
||||||
|
dk, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDatenklasse: %v", err)
|
||||||
|
}
|
||||||
|
ei, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
an, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnforderungen: %v", err)
|
||||||
|
}
|
||||||
|
return web.Regelwerk{Datenklasse: dk, Einstufung: ei, Anforderungen: an}
|
||||||
|
}
|
||||||
|
|
||||||
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, err := web.NewServer(fs)
|
s, err := web.NewServer(fs, loadTestRegelwerk(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewServer: %v", err)
|
t.Fatalf("NewServer: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,64 @@
|
|||||||
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
||||||
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
||||||
|
|
||||||
<p class="hinweis">
|
{{if .Bewertung}}
|
||||||
Die Bewertung (Datenklasse, KI-VO-Einstufung, Werkzeugvorschlag) folgt
|
<h2>Bewertung</h2>
|
||||||
in einer späteren Ausbaustufe — dieser Antrag ist gespeichert und
|
|
||||||
eingereicht.
|
{{if .Bewertung.Verboten}}
|
||||||
|
<p class="fehler"><strong>Verboten.</strong> Diese Nutzung fällt laut Einstufung unter ein verbotenes KI-Praktiken-Muster der KI-VO ({{.Bewertung.Einstufung}}). {{.Bewertung.EinstufungHerleitung}}</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p><strong>Datenklasse:</strong> {{.Bewertung.Datenklasse}}<br>
|
||||||
|
<span class="hinweis">{{.Bewertung.DatenklasseHerleitung}}</span></p>
|
||||||
|
|
||||||
|
<p><strong>KI-VO-Einstufung:</strong> {{.Bewertung.Einstufung}}<br>
|
||||||
|
<span class="hinweis">{{.Bewertung.EinstufungHerleitung}}</span></p>
|
||||||
|
|
||||||
|
{{if .Bewertung.Anforderungen}}
|
||||||
|
<h3>Anforderungen</h3>
|
||||||
|
<ul class="findings">
|
||||||
|
{{range .Bewertung.Anforderungen}}
|
||||||
|
<li class="finding finding-mittel">
|
||||||
|
<strong>{{.Beschreibung}}</strong>
|
||||||
|
<p>{{.Herleitung}}</p>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if not .Bewertung.Verboten}}
|
||||||
|
<h3>Zulässige Werkzeuge</h3>
|
||||||
|
{{if .Bewertung.ZulaessigeWerkzeuge}}
|
||||||
|
<ul>
|
||||||
|
{{range .Bewertung.ZulaessigeWerkzeuge}}<li>{{.}}</li>{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="rueckfrage">Kein Werkzeug im Katalog erfüllt aktuell alle Anforderungen dieses Antrags.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Bewertung.AusgeschlosseneWerkzeuge}}
|
||||||
|
<h3>Ausgeschlossene Werkzeuge</h3>
|
||||||
|
<ul class="findings">
|
||||||
|
{{range .Bewertung.AusgeschlosseneWerkzeuge}}
|
||||||
|
<li class="finding finding-niedrig">
|
||||||
|
<strong>{{.Name}}</strong>
|
||||||
|
<p>Nicht erfüllt: {{range $i, $a := .NichtErfuellt}}{{if $i}}, {{end}}{{$a}}{{end}}</p>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p class="disclaimer">
|
||||||
|
Das System entscheidet nicht. Es bereitet vor. Diese Einstufung ist ein
|
||||||
|
Vorschlag zur Herleitung von Datenklasse, KI-VO-Einstufung und
|
||||||
|
Anforderungen (Regelwerk {{.Bewertung.RegelwerkVersion}}, Katalog
|
||||||
|
{{.Bewertung.KatalogVersion}}, berechnet am {{.Bewertung.CreatedAt}}) —
|
||||||
|
die Entscheidung trifft ein Mensch.
|
||||||
</p>
|
</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="hinweis">Die Bewertung wird berechnet, sobald der Antrag eingereicht ist.</p>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user