From e968cf9761e9fef12b0c4d5ab67db6fb6c2f4031 Mon Sep 17 00:00:00 2001 From: noroot Date: Sat, 29 Aug 2026 10:22:24 +0200 Subject: [PATCH] feat: Ergebnisdarstellung mit Herleitung (Schritt 4 der Baureihenfolge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 23 ++- cmd/deklarix/main.go | 4 +- internal/rules/evaluate.go | 91 ++++++++--- internal/rules/evaluate_test.go | 47 +++--- internal/store/bewertung.go | 139 +++++++++++++++++ internal/store/bewertung_test.go | 126 +++++++++++++++ .../store/migrations/0009_bewertung.down.sql | 1 + .../store/migrations/0009_bewertung.up.sql | 26 ++++ internal/web/antrag_handlers.go | 144 +++++++++++++++++- internal/web/antrag_handlers_test.go | 119 +++++++++++++++ internal/web/server.go | 24 ++- internal/web/server_test.go | 84 +++++++++- internal/web/templates/antrag_detail.html | 61 +++++++- 13 files changed, 835 insertions(+), 54 deletions(-) create mode 100644 internal/store/bewertung.go create mode 100644 internal/store/bewertung_test.go create mode 100644 internal/store/migrations/0009_bewertung.down.sql create mode 100644 internal/store/migrations/0009_bewertung.up.sql diff --git a/CLAUDE.md b/CLAUDE.md index df3b8b5..ee89bd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,10 +147,13 @@ und wertet sie seit Schritt 3 auch aus: `EvaluateDatenklasse`, `FilterWerkzeuge`/`ErfuelltAnforderung` filtern einen Werkzeugkatalog hart gegen die abgeleiteten Anforderungen (`WerkzeugEigenschaften` ist ein schlanker, von `store.Werkzeug` unabhängiger Typ, damit -`internal/rules` weiterhin ohne `internal/store` auskommt). **Noch -nicht verdrahtet:** eine Web-Seite, die das für einen konkreten Antrag -tatsächlich aufruft und anzeigt — das ist Schritt 4 -("Ergebnisdarstellung mit Herleitung"). +`internal/rules` weiterhin ohne `internal/store` auskommt). Seit +Schritt 4 wird das auch tatsächlich aufgerufen: `POST /antraege` +berechnet nach dem Einreichen sofort eine `bewertung` (append-only, +`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`: `EvaluateDatenklasse`/`EvaluateEinstufung`/`DeriveAnforderungen`/ `IstVerboten`/`FilterWerkzeuge`, vollständig getestet gegen die - echten `rules/*.yaml`-Dateien. Noch nicht ans Web angebunden.) -4. Ergebnisdarstellung mit Herleitung + echten `rules/*.yaml`-Dateien.) +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 6. Registereintrag und Export 7. Wiedervorlage und Katalog-Benachrichtigung diff --git a/cmd/deklarix/main.go b/cmd/deklarix/main.go index 56e85d8..400e902 100644 --- a/cmd/deklarix/main.go +++ b/cmd/deklarix/main.go @@ -51,7 +51,9 @@ func main() { log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)", 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 { log.Fatalf("web server: %v", err) } diff --git a/internal/rules/evaluate.go b/internal/rules/evaluate.go index 74890f1..5b164d4 100644 --- a/internal/rules/evaluate.go +++ b/internal/rules/evaluate.go @@ -3,6 +3,7 @@ package rules import ( "encoding/json" "fmt" + "sort" "strings" ) @@ -33,6 +34,14 @@ func (a Antworten) istJaOderUnsicher(key string) bool { 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. // "Höchste zutreffende Stufe gewinnt" (höherer Rang gewinnt). Trifft // 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 // "intern" angenommen statt "oeffentlich": im Zweifel mehr Schutz, nicht // weniger. Siehe rules/OPEN.md, Punkt 5. -func EvaluateDatenklasse(regelwerk DatenklasseRegelwerk, antworten Antworten) string { +func EvaluateDatenklasse(regelwerk DatenklasseRegelwerk, antworten Antworten) DatenklasseErgebnis { const fallback = "intern" var gewinner *DatenklasseStufe + var treffer []string for i := range regelwerk.Stufen { st := ®elwerk.Stufen[i] + var stTreffer []string for _, ausloeser := range st.Ausloeser { if antworten.istJaOderUnsicher(ausloeser) { - if gewinner == nil || st.Rang > gewinner.Rang { - gewinner = st - } - break + stTreffer = append(stTreffer, ausloeser) } } + if len(stTreffer) > 0 && (gewinner == nil || st.Rang > gewinner.Rang) { + gewinner = st + treffer = stTreffer + } } 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 @@ -86,24 +98,46 @@ func variantePasst(variante EinstufungVariante, antworten Antworten) bool { 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. // Prüfreihenfolge: die erste zutreffende Stufe gewinnt (siehe // Bewertungslogik, K.-o.-Prüfung) — deshalb steht "verboten" in // rules/kivo_einstufung.yaml an erster Stelle. Liefert einen Fehler nur, // wenn das Regelwerk selbst keine Auffangregel definiert (sollte durch // 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 { 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 { 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" @@ -112,13 +146,27 @@ func IstVerboten(einstufungID string) bool { 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 // der Anforderungen ab, die ein Werkzeug erfüllen muss. -func DeriveAnforderungen(regelwerk AnforderungsRegelwerk, datenklasseID, einstufungID string) []string { - var out []string +func DeriveAnforderungen(regelwerk AnforderungsRegelwerk, datenklasseID, einstufungID string) []AnforderungErgebnis { + var out []AnforderungErgebnis for _, a := range regelwerk.Anforderungen { - if containsString(a.AusDatenklassen, datenklasseID) || containsString(a.AusEinstufungen, einstufungID) { - out = append(out, a.ID) + var gruende []string + 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 @@ -200,8 +248,17 @@ func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string) (zu return zulaessig, ausgeschlossen } -// String liefert eine lesbare Begründung, z. B. für die spätere -// Ergebnisdarstellung (Schritt 4). +// String liefert eine lesbare Begründung, z. B. für die +// Ergebnisdarstellung. func (a AusschlussGrund) String() string { 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) +} diff --git a/internal/rules/evaluate_test.go b/internal/rules/evaluate_test.go index 983be87..26ec598 100644 --- a/internal/rules/evaluate_test.go +++ b/internal/rules/evaluate_test.go @@ -28,8 +28,8 @@ func TestEvaluateDatenklasseOnlyOeffentlich(t *testing.T) { dk, _, _ := loadRealRegelwerke(t) antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "ja"} got := rules.EvaluateDatenklasse(dk, antworten) - if got != "oeffentlich" { - t.Fatalf("EvaluateDatenklasse = %q, want oeffentlich", got) + if got.ID != "oeffentlich" { + 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. antworten := rules.Antworten{"b1": "ja", "b2": "ja", "b7": "nein"} got := rules.EvaluateDatenklasse(dk, antworten) - if got != "besondere_kategorie" { - t.Fatalf("EvaluateDatenklasse = %q, want besondere_kategorie", got) + if got.ID != "besondere_kategorie" { + t.Fatalf("EvaluateDatenklasse.ID = %q, want besondere_kategorie", got.ID) } } @@ -48,8 +48,8 @@ func TestEvaluateDatenklasseUnsicherZaehltWieJa(t *testing.T) { dk, _, _ := loadRealRegelwerke(t) antworten := rules.Antworten{"b3": "unsicher"} got := rules.EvaluateDatenklasse(dk, antworten) - if got != "berufsgeheimnis" { - t.Fatalf("EvaluateDatenklasse = %q, want berufsgeheimnis (unsicher zaehlt wie ja)", got) + if got.ID != "berufsgeheimnis" { + 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) antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "nein"} got := rules.EvaluateDatenklasse(dk, antworten) - if got != "intern" { - t.Fatalf("EvaluateDatenklasse = %q, want intern (konservativer Fallback)", got) + if got.ID != "intern" { + t.Fatalf("EvaluateDatenklasse.ID = %q, want intern (konservativer Fallback)", got.ID) } } @@ -69,10 +69,10 @@ func TestEvaluateEinstufungVerboten(t *testing.T) { if err != nil { t.Fatalf("EvaluateEinstufung: %v", err) } - if got != "verboten" { - t.Fatalf("EvaluateEinstufung = %q, want verboten", got) + if got.ID != "verboten" { + t.Fatalf("EvaluateEinstufung.ID = %q, want verboten", got.ID) } - if !rules.IstVerboten(got) { + if !rules.IstVerboten(got.ID) { t.Error("IstVerboten sollte true liefern") } } @@ -84,8 +84,8 @@ func TestEvaluateEinstufungHochrisiko(t *testing.T) { if err != nil { t.Fatalf("EvaluateEinstufung: %v", err) } - if got != "hochrisiko" { - t.Fatalf("EvaluateEinstufung = %q, want hochrisiko", got) + if got.ID != "hochrisiko" { + t.Fatalf("EvaluateEinstufung.ID = %q, want hochrisiko", got.ID) } } @@ -96,8 +96,8 @@ func TestEvaluateEinstufungTransparenzpflicht(t *testing.T) { if err != nil { t.Fatalf("EvaluateEinstufung: %v", err) } - if got != "transparenzpflicht" { - t.Fatalf("EvaluateEinstufung = %q, want transparenzpflicht", got) + if got.ID != "transparenzpflicht" { + t.Fatalf("EvaluateEinstufung.ID = %q, want transparenzpflicht", got.ID) } } @@ -108,10 +108,10 @@ func TestEvaluateEinstufungMinimalFallback(t *testing.T) { if err != nil { t.Fatalf("EvaluateEinstufung: %v", err) } - if got != "minimal" { - t.Fatalf("EvaluateEinstufung = %q, want minimal", got) + if got.ID != "minimal" { + 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") } } @@ -121,8 +121,11 @@ func TestDeriveAnforderungenPersonenbezogen(t *testing.T) { got := rules.DeriveAnforderungen(an, "personenbezogen", "minimal") want := map[string]bool{"avv_erforderlich": true, "kein_training_auf_eingabe": true, "loeschfrist_max_tage": true} gotSet := map[string]bool{} - for _, id := range got { - gotSet[id] = true + for _, a := range got { + gotSet[a.ID] = true + if a.Herleitung == "" { + t.Errorf("Anforderung %q hat keine Herleitung", a.ID) + } } for id := range want { if !gotSet[id] { @@ -138,8 +141,8 @@ func TestDeriveAnforderungenHochrisikoAddsAufsichtUndDsfa(t *testing.T) { _, _, an := loadRealRegelwerke(t) got := rules.DeriveAnforderungen(an, "oeffentlich", "hochrisiko") gotSet := map[string]bool{} - for _, id := range got { - gotSet[id] = true + for _, a := range got { + gotSet[a.ID] = true } if !gotSet["menschliche_aufsicht"] || !gotSet["dsfa_erforderlich"] { t.Errorf("expected menschliche_aufsicht und dsfa_erforderlich fuer hochrisiko, got %v", got) diff --git a/internal/store/bewertung.go b/internal/store/bewertung.go new file mode 100644 index 0000000..06178b3 --- /dev/null +++ b/internal/store/bewertung.go @@ -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 +} diff --git a/internal/store/bewertung_test.go b/internal/store/bewertung_test.go new file mode 100644 index 0000000..39f65f1 --- /dev/null +++ b/internal/store/bewertung_test.go @@ -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") + } +} diff --git a/internal/store/migrations/0009_bewertung.down.sql b/internal/store/migrations/0009_bewertung.down.sql new file mode 100644 index 0000000..baf2197 --- /dev/null +++ b/internal/store/migrations/0009_bewertung.down.sql @@ -0,0 +1 @@ +DROP TABLE bewertung; diff --git a/internal/store/migrations/0009_bewertung.up.sql b/internal/store/migrations/0009_bewertung.up.sql new file mode 100644 index 0000000..029d47a --- /dev/null +++ b/internal/store/migrations/0009_bewertung.up.sql @@ -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(); diff --git a/internal/web/antrag_handlers.go b/internal/web/antrag_handlers.go index d2ad1fe..148847b 100644 --- a/internal/web/antrag_handlers.go +++ b/internal/web/antrag_handlers.go @@ -1,9 +1,11 @@ package web import ( + "context" "encoding/json" "net/http" + "github.com/netcell-it/deklarix/internal/rules" "github.com/netcell-it/deklarix/internal/store" ) @@ -118,12 +120,14 @@ func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) { ctx := r.Context() user := currentUser(r) + antworten := antwortenFromForm(r) + antrag, err := s.store.CreateAntrag(ctx, user.AccountID, user.ID, abteilungID, titel) if err != nil { http.Error(w, "Antrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) 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) 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) 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) } +// 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 { ID 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 { Title string Nav navData @@ -184,6 +286,7 @@ type antragDetailData struct { Status string CreatedAt string Antworten map[string]any + Bewertung *bewertungView } // 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, 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 { 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 +} diff --git a/internal/web/antrag_handlers_test.go b/internal/web/antrag_handlers_test.go index c437715..e86b06e 100644 --- a/internal/web/antrag_handlers_test.go +++ b/internal/web/antrag_handlers_test.go @@ -7,6 +7,8 @@ import ( "net/url" "strings" "testing" + + "github.com/netcell-it/deklarix/internal/store" ) 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) { fs := newFakeStore() s := newServer(t, fs) diff --git a/internal/web/server.go b/internal/web/server.go index af28070..7147e44 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -13,6 +13,7 @@ import ( "net/http" "time" + "github.com/netcell-it/deklarix/internal/rules" "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) SetAntragStatus(ctx context.Context, id, status string) 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. type Server struct { mux *http.ServeMux store Store + regelwerk Regelwerk templates *template.Template } -// NewServer erstellt den Server. -func NewServer(st Store) (*Server, error) { +// NewServer erstellt den Server. regelwerk kommt von rules.Load* und +// wird einmal beim Start geladen, nicht pro Request. +func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { tmpl, err := template.ParseFS(templatesFS, "templates/*.html") if err != nil { return nil, fmt.Errorf("web: templates parsen: %w", err) @@ -64,6 +83,7 @@ func NewServer(st Store) (*Server, error) { s := &Server{ store: st, + regelwerk: regelwerk, templates: tmpl, } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 649571b..8ad9c9e 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -7,12 +7,14 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strings" "sync" "testing" "time" "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/web" ) @@ -31,6 +33,8 @@ type fakeStore struct { auditLog []store.AuditEntry abteilungen map[string][]store.Abteilung // accountID -> Abteilungen antraege map[string]store.Antrag + werkzeuge map[string]store.Werkzeug + bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst } func newFakeStore() *fakeStore { @@ -41,6 +45,8 @@ func newFakeStore() *fakeStore { sessions: map[string]store.Session{}, abteilungen: map[string][]store.Abteilung{}, 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 } +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 ─────────────────────────────────────────────────────── +// 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 { t.Helper() - s, err := web.NewServer(fs) + s, err := web.NewServer(fs, loadTestRegelwerk(t)) if err != nil { t.Fatalf("NewServer: %v", err) } diff --git a/internal/web/templates/antrag_detail.html b/internal/web/templates/antrag_detail.html index 5bbf7f3..48a025e 100644 --- a/internal/web/templates/antrag_detail.html +++ b/internal/web/templates/antrag_detail.html @@ -13,11 +13,64 @@

Was soll herauskommen?
{{.Ergebnis}}

Häufigkeit: {{.Haeufigkeit}}

-

- Die Bewertung (Datenklasse, KI-VO-Einstufung, Werkzeugvorschlag) folgt - in einer späteren Ausbaustufe — dieser Antrag ist gespeichert und - eingereicht. +{{if .Bewertung}} +

Bewertung

+ +{{if .Bewertung.Verboten}} +

Verboten. Diese Nutzung fällt laut Einstufung unter ein verbotenes KI-Praktiken-Muster der KI-VO ({{.Bewertung.Einstufung}}). {{.Bewertung.EinstufungHerleitung}}

+{{end}} + +

Datenklasse: {{.Bewertung.Datenklasse}}
+{{.Bewertung.DatenklasseHerleitung}}

+ +

KI-VO-Einstufung: {{.Bewertung.Einstufung}}
+{{.Bewertung.EinstufungHerleitung}}

+ +{{if .Bewertung.Anforderungen}} +

Anforderungen

+ +{{end}} + +{{if not .Bewertung.Verboten}} +

Zulässige Werkzeuge

+{{if .Bewertung.ZulaessigeWerkzeuge}} + +{{else}} +

Kein Werkzeug im Katalog erfüllt aktuell alle Anforderungen dieses Antrags.

+{{end}} + +{{if .Bewertung.AusgeschlosseneWerkzeuge}} +

Ausgeschlossene Werkzeuge

+ +{{end}} +{{end}} + +

+ 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.

+{{else}} +

Die Bewertung wird berechnet, sobald der Antrag eingereicht ist.

+{{end}}