feat: Ableitungen und harte Filter (Schritt 3 der Baureihenfolge)
Reine Auswertungslogik in internal/rules, operiert auf rules.Antworten (geparst aus antrag.antworten) und den drei Regelwerken aus Schritt 1 — ohne DB-/Web-Zugriff, vollständig isoliert testbar. - EvaluateDatenklasse: "höchste zutreffende Stufe gewinnt" (höherer Rang). Trifft keine Stufe zu (z. B. B7 fälschlich "nein" trotz keiner anderen Kategorie), wird konservativ "intern" angenommen statt "oeffentlich" — im Zweifel mehr Schutz. Nicht fachlich bestätigt, siehe rules/OPEN.md Punkt 5. - EvaluateEinstufung: Prüfreihenfolge wie im Regelwerk (verboten zuerst = K.-o.-Prüfung), erste zutreffende Stufe/Variante gewinnt. IstVerboten prüft die K.-o.-Bedingung direkt. - DeriveAnforderungen: Anforderungsprofil aus Datenklasse+Einstufung. - FilterWerkzeuge/ErfuelltAnforderung: harter Filter gegen einen Werkzeugkatalog. WerkzeugEigenschaften ist ein eigener, schlanker Typ statt store.Werkzeug — internal/rules bleibt unabhängig von internal/store. Nur technische Werkzeug-Eigenschaften (avv_erforderlich, eu_verarbeitung, kein_training_auf_eingabe) werden hart gefiltert; Prozess-Anforderungen (menschliche_aufsicht, kennzeichnungspflicht, dsfa_erforderlich) sortieren kein Werkzeug aus, sondern werden später als Auflage vermerkt — Annahme, siehe rules/OPEN.md Punkt 6. rules/OPEN.md um Punkt 4 (Auswirkung der fehlenden Löschfristen auf den Filter), Punkt 5 (Datenklasse-Fallback) und Punkt 6 (welche Anforderungen hart filtern) ergänzt — Annahmen dokumentiert statt geraten, damit Schritt 3 nicht auf die fachliche Klärung warten musste. 16 neue Tests gegen die ECHTEN rules/*.yaml-Dateien (nicht nur synthetische Fixtures) — deckt Rang-Konflikte, "Unsicher zählt wie Ja", alle vier Einstufungsstufen inkl. Auffangregel, Anforderungs-Ableitung und Werkzeug-Filterung ab. Noch nicht ans Web angebunden (Schritt 4).
This commit is contained in:
29
CLAUDE.md
29
CLAUDE.md
@@ -139,11 +139,18 @@ Werkzeug: `avv_erforderlich`, `eu_verarbeitung`,
|
|||||||
`menschliche_aufsicht`, `kennzeichnungspflicht`, `dsfa_erforderlich`.
|
`menschliche_aufsicht`, `kennzeichnungspflicht`, `dsfa_erforderlich`.
|
||||||
|
|
||||||
`internal/rules` lädt und validiert diese drei Dateien (eindeutige IDs,
|
`internal/rules` lädt und validiert diese drei Dateien (eindeutige IDs,
|
||||||
eindeutige Ränge, jede Anforderung braucht mindestens einen Auslöser).
|
eindeutige Ränge, jede Anforderung braucht mindestens einen Auslöser)
|
||||||
**Die Auswertung gegen echte Fragebogen-Antworten ist noch nicht
|
und wertet sie seit Schritt 3 auch aus: `EvaluateDatenklasse`,
|
||||||
gebaut** — das ist Schritt 3 der Baureihenfolge (siehe unten), bewusst
|
`EvaluateEinstufung`, `IstVerboten`, `DeriveAnforderungen` operieren auf
|
||||||
erst, wenn der Fragebogen (Schritt 2) die exakten Fakten-Feldnamen
|
`rules.Antworten` (geparst aus `antrag.antworten` via `ParseAntworten`)
|
||||||
festlegt.
|
— reine, für sich getestete Funktionen ohne DB-/Web-Zugriff.
|
||||||
|
`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").
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -318,7 +325,10 @@ wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/
|
|||||||
JavaScript. `antworten`-JSON nutzt exakt die Fakten-Schlüssel aus
|
JavaScript. `antworten`-JSON nutzt exakt die Fakten-Schlüssel aus
|
||||||
`rules/*.yaml` (b1-b7, c1-c5, c2_folge, c3_art) — das hat auch die
|
`rules/*.yaml` (b1-b7, c1-c5, c2_folge, c3_art) — das hat auch die
|
||||||
OPEN.md-Frage zu den "verboten"-Fragebogen-Feldern final geklärt.)
|
OPEN.md-Frage zu den "verboten"-Fragebogen-Feldern final geklärt.)
|
||||||
3. Ableitungen und harte Filter (Auswertung gegen echte Antworten)
|
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
|
4. Ergebnisdarstellung mit Herleitung
|
||||||
5. Entscheidung, Snapshot, Audit-Log
|
5. Entscheidung, Snapshot, Audit-Log
|
||||||
6. Registereintrag und Export
|
6. Registereintrag und Export
|
||||||
@@ -532,8 +542,11 @@ journalctl -u deklarix -f
|
|||||||
## Offene Punkte
|
## Offene Punkte
|
||||||
|
|
||||||
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil**
|
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil**
|
||||||
(siehe `rules/OPEN.md`) — vor Schritt 3 mit dem/der fachlich
|
(siehe `rules/OPEN.md`, Punkte 1/2/5/6) — mit dem/der fachlich
|
||||||
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen.
|
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. Schritt
|
||||||
|
3 ist trotzdem schon umgesetzt (auf Basis dieser dokumentierten
|
||||||
|
Annahmen) — nicht auf die Klärung gewartet, um nicht blockiert zu
|
||||||
|
bleiben, aber die Ableitung kann sich noch ändern.
|
||||||
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
|
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
|
||||||
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`.
|
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`.
|
||||||
Weiterhin offen: ob die drei Varianten fachlich vollständig sind
|
Weiterhin offen: ob die drei Varianten fachlich vollständig sind
|
||||||
|
|||||||
207
internal/rules/evaluate.go
Normal file
207
internal/rules/evaluate.go
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Antworten ist die im Antrag gespeicherte Fragebogen-Antwort-Menge —
|
||||||
|
// dieselbe Struktur, die internal/web/antrag_handlers.go erzeugt
|
||||||
|
// (antwortenFromForm): ein flaches JSON-Objekt mit denselben
|
||||||
|
// Fakten-Schlüsseln wie in den Regelwerk-YAML-Dateien (b1..b7, c1..c5,
|
||||||
|
// c2_folge, c3_art).
|
||||||
|
type Antworten map[string]any
|
||||||
|
|
||||||
|
// ParseAntworten liest antrag.antworten (rohes JSON aus der Datenbank).
|
||||||
|
func ParseAntworten(raw []byte) (Antworten, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return Antworten{}, nil
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("rules: antworten parsen: %w", err)
|
||||||
|
}
|
||||||
|
return Antworten(m), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// istJaOderUnsicher prüft eine B-Frage. "Unsicher zählt wie Ja" ist eine
|
||||||
|
// Auswertungsregel (siehe CLAUDE.md, Fragebogen-Abschnitt B) — deshalb
|
||||||
|
// hier und nicht schon beim Speichern des Antrags angewendet.
|
||||||
|
func (a Antworten) istJaOderUnsicher(key string) bool {
|
||||||
|
v, _ := a[key].(string)
|
||||||
|
return v == "ja" || v == "unsicher"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
// beantwortet wurde, obwohl keine andere Kategorie zutrifft — ein
|
||||||
|
// 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 {
|
||||||
|
const fallback = "intern"
|
||||||
|
var gewinner *DatenklasseStufe
|
||||||
|
for i := range regelwerk.Stufen {
|
||||||
|
st := ®elwerk.Stufen[i]
|
||||||
|
for _, ausloeser := range st.Ausloeser {
|
||||||
|
if antworten.istJaOderUnsicher(ausloeser) {
|
||||||
|
if gewinner == nil || st.Rang > gewinner.Rang {
|
||||||
|
gewinner = st
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gewinner == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return gewinner.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantePasst prüft, ob alle Schlüssel-Werte-Paare einer Variante zu
|
||||||
|
// den Antworten passen (UND-Verknüpfung innerhalb der Variante).
|
||||||
|
func variantePasst(variante EinstufungVariante, antworten Antworten) bool {
|
||||||
|
for key, want := range variante {
|
||||||
|
got, ok := antworten[key]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch wantVal := want.(type) {
|
||||||
|
case bool:
|
||||||
|
gotBool, ok := got.(bool)
|
||||||
|
if !ok || gotBool != wantVal {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
gotStr, ok := got.(string)
|
||||||
|
if !ok || gotStr != wantVal {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
for _, st := range regelwerk.Stufen {
|
||||||
|
if len(st.Varianten) == 0 {
|
||||||
|
return st.ID, nil // Auffangregel, z. B. "minimal"
|
||||||
|
}
|
||||||
|
for _, variante := range st.Varianten {
|
||||||
|
if variantePasst(variante, antworten) {
|
||||||
|
return st.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("rules: keine einstufung trifft zu und keine auffangregel definiert")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IstVerboten prüft die K.-o.-Bedingung (Art. 5 KI-VO): bei "verboten"
|
||||||
|
// erfolgt sofortige Ablehnung, keine Werkzeugsuche.
|
||||||
|
func IstVerboten(einstufungID string) bool {
|
||||||
|
return einstufungID == "verboten"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
for _, a := range regelwerk.Anforderungen {
|
||||||
|
if containsString(a.AusDatenklassen, datenklasseID) || containsString(a.AusEinstufungen, einstufungID) {
|
||||||
|
out = append(out, a.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(list []string, v string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// WerkzeugEigenschaften sind die für die harte Filterung relevanten
|
||||||
|
// Felder eines Katalogeintrags — bewusst ein eigener, schlanker Typ
|
||||||
|
// statt store.Werkzeug direkt zu verwenden: internal/rules bleibt so
|
||||||
|
// unabhängig von internal/store und für sich allein testbar.
|
||||||
|
type WerkzeugEigenschaften struct {
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
Verarbeitungsort string
|
||||||
|
TrainingOptOut bool
|
||||||
|
TrainingStandard bool
|
||||||
|
AufbewahrungTage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung
|
||||||
|
// erfüllt. Anforderungen, die einen Prozess statt eine technische
|
||||||
|
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
||||||
|
// DSFA) werden hier nicht hart gefiltert — sie werden als Auflage
|
||||||
|
// vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das
|
||||||
|
// Werkzeug selbst. loeschfrist_max_tage hat noch keine fachlich
|
||||||
|
// festgelegten Tageswerte (rules/OPEN.md, Punkt 4) und wird deshalb
|
||||||
|
// ebenfalls noch nicht hart gefiltert.
|
||||||
|
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
|
||||||
|
switch anforderungID {
|
||||||
|
case "avv_erforderlich":
|
||||||
|
return w.AVVVerfuegbar
|
||||||
|
case "eu_verarbeitung":
|
||||||
|
return w.Verarbeitungsort == "EU"
|
||||||
|
case "kein_training_auf_eingabe":
|
||||||
|
return w.TrainingStandard
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AusschlussGrund hält fest, warum ein Werkzeug aussortiert wurde —
|
||||||
|
// auch aussortierte Werkzeuge werden im Ergebnis gezeigt (siehe
|
||||||
|
// Bewertungslogik), nie stillschweigend weggelassen.
|
||||||
|
type AusschlussGrund struct {
|
||||||
|
WerkzeugID string
|
||||||
|
NichtErfuellt []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// WerkzeugKandidat ist ein Katalogeintrag im harten Filter.
|
||||||
|
type WerkzeugKandidat struct {
|
||||||
|
ID string
|
||||||
|
Eigenschaften WerkzeugEigenschaften
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine
|
||||||
|
// Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene
|
||||||
|
// Werkzeug fest, welche Anforderungen fehlten.
|
||||||
|
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
||||||
|
for _, k := range kandidaten {
|
||||||
|
var fehlend []string
|
||||||
|
for _, reqID := range anforderungIDs {
|
||||||
|
if !ErfuelltAnforderung(reqID, k.Eigenschaften) {
|
||||||
|
fehlend = append(fehlend, reqID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fehlend) == 0 {
|
||||||
|
zulaessig = append(zulaessig, k.ID)
|
||||||
|
} else {
|
||||||
|
ausgeschlossen = append(ausgeschlossen, AusschlussGrund{WerkzeugID: k.ID, NichtErfuellt: fehlend})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zulaessig, ausgeschlossen
|
||||||
|
}
|
||||||
|
|
||||||
|
// String liefert eine lesbare Begründung, z. B. für die spätere
|
||||||
|
// Ergebnisdarstellung (Schritt 4).
|
||||||
|
func (a AusschlussGrund) String() string {
|
||||||
|
return fmt.Sprintf("%s: erfüllt nicht %s", a.WerkzeugID, strings.Join(a.NichtErfuellt, ", "))
|
||||||
|
}
|
||||||
208
internal/rules/evaluate_test.go
Normal file
208
internal/rules/evaluate_test.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package rules_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadRealRegelwerke(t *testing.T) (rules.DatenklasseRegelwerk, rules.EinstufungRegelwerk, rules.AnforderungsRegelwerk) {
|
||||||
|
t.Helper()
|
||||||
|
fsys := realRulesFS(t)
|
||||||
|
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 dk, ei, an
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateDatenklasseHighestWins(t *testing.T) {
|
||||||
|
dk, _, _ := loadRealRegelwerke(t)
|
||||||
|
// b1 (personenbezogen) UND b2 (besondere_kategorie) beide ja ->
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEinstufungVerboten(t *testing.T) {
|
||||||
|
_, ei, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"c3": true, "c3_art": "social_scoring"}
|
||||||
|
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
if got != "verboten" {
|
||||||
|
t.Fatalf("EvaluateEinstufung = %q, want verboten", got)
|
||||||
|
}
|
||||||
|
if !rules.IstVerboten(got) {
|
||||||
|
t.Error("IstVerboten sollte true liefern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEinstufungHochrisiko(t *testing.T) {
|
||||||
|
_, ei, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"c2": true, "c2_folge": "kreditwuerdigkeit", "c3": false}
|
||||||
|
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
if got != "hochrisiko" {
|
||||||
|
t.Fatalf("EvaluateEinstufung = %q, want hochrisiko", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEinstufungTransparenzpflicht(t *testing.T) {
|
||||||
|
_, ei, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"c1": true, "c2": false, "c3": false, "c5": false}
|
||||||
|
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
if got != "transparenzpflicht" {
|
||||||
|
t.Fatalf("EvaluateEinstufung = %q, want transparenzpflicht", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEinstufungMinimalFallback(t *testing.T) {
|
||||||
|
_, ei, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"c1": false, "c2": false, "c3": false, "c4": false, "c5": true}
|
||||||
|
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
if got != "minimal" {
|
||||||
|
t.Fatalf("EvaluateEinstufung = %q, want minimal", got)
|
||||||
|
}
|
||||||
|
if rules.IstVerboten(got) {
|
||||||
|
t.Error("IstVerboten sollte fuer minimal false liefern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveAnforderungenPersonenbezogen(t *testing.T) {
|
||||||
|
_, _, an := loadRealRegelwerke(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 id := range want {
|
||||||
|
if !gotSet[id] {
|
||||||
|
t.Errorf("expected Anforderung %q for personenbezogen/minimal, got %v", id, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gotSet["eu_verarbeitung"] {
|
||||||
|
t.Error("eu_verarbeitung sollte fuer personenbezogen (ohne berufsgeheimnis/besondere_kategorie) nicht ausgeloest werden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if !gotSet["menschliche_aufsicht"] || !gotSet["dsfa_erforderlich"] {
|
||||||
|
t.Errorf("expected menschliche_aufsicht und dsfa_erforderlich fuer hochrisiko, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungAVV(t *testing.T) {
|
||||||
|
if rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: false}) {
|
||||||
|
t.Error("erwartet: nicht erfuellt ohne AVV")
|
||||||
|
}
|
||||||
|
if !rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: true}) {
|
||||||
|
t.Error("erwartet: erfuellt mit AVV")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
||||||
|
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) {
|
||||||
|
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
||||||
|
kandidaten := []rules.WerkzeugKandidat{
|
||||||
|
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungsort: "EU", TrainingStandard: true}},
|
||||||
|
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungsort: "EU", TrainingStandard: true}},
|
||||||
|
}
|
||||||
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"})
|
||||||
|
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
||||||
|
t.Fatalf("zulaessig = %v, want [mit-avv]", zulaessig)
|
||||||
|
}
|
||||||
|
if len(ausgeschlossen) != 1 || ausgeschlossen[0].WerkzeugID != "ohne-avv" {
|
||||||
|
t.Fatalf("ausgeschlossen = %v, want genau ohne-avv", ausgeschlossen)
|
||||||
|
}
|
||||||
|
if len(ausgeschlossen[0].NichtErfuellt) != 1 || ausgeschlossen[0].NichtErfuellt[0] != "avv_erforderlich" {
|
||||||
|
t.Fatalf("NichtErfuellt = %v, want [avv_erforderlich]", ausgeschlossen[0].NichtErfuellt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterWerkzeugeNoRequirementsAllowsEverything(t *testing.T) {
|
||||||
|
kandidaten := []rules.WerkzeugKandidat{{ID: "x"}, {ID: "y"}}
|
||||||
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, nil)
|
||||||
|
if len(zulaessig) != 2 || len(ausgeschlossen) != 0 {
|
||||||
|
t.Fatalf("zulaessig=%v ausgeschlossen=%v, want beide zulaessig", zulaessig, ausgeschlossen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAntwortenRoundTrip(t *testing.T) {
|
||||||
|
raw := []byte(`{"b1":"ja","c2":true,"c2_folge":"bildung"}`)
|
||||||
|
a, err := rules.ParseAntworten(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAntworten: %v", err)
|
||||||
|
}
|
||||||
|
if a["b1"] != "ja" || a["c2_folge"] != "bildung" {
|
||||||
|
t.Fatalf("ParseAntworten = %+v, unerwartete Werte", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAntwortenEmpty(t *testing.T) {
|
||||||
|
a, err := rules.ParseAntworten(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAntworten: %v", err)
|
||||||
|
}
|
||||||
|
if len(a) != 0 {
|
||||||
|
t.Fatalf("ParseAntworten(nil) = %+v, want empty", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,4 +54,32 @@ Datenklasse", ohne die konkreten Tageswerte pro Stufe zu nennen.
|
|||||||
`rules/anforderungen.yaml` listet die Anforderung nur als grundsätzlich
|
`rules/anforderungen.yaml` listet die Anforderung nur als grundsätzlich
|
||||||
anwendbar (welche Datenklassen sie überhaupt auslösen), ohne
|
anwendbar (welche Datenklassen sie überhaupt auslösen), ohne
|
||||||
Zahlenwerte — die eigentlichen Fristen (z. B. "personenbezogen: 90
|
Zahlenwerte — die eigentlichen Fristen (z. B. "personenbezogen: 90
|
||||||
Tage") müssen vor Phase 3 fachlich festgelegt werden.
|
Tage") müssen vor Phase 3 fachlich festgelegt werden. **Auswirkung auf
|
||||||
|
Schritt 3 (`internal/rules.ErfuelltAnforderung`):** ohne Zahlenwerte
|
||||||
|
wird diese Anforderung aktuell NICHT hart gegen Werkzeuge gefiltert
|
||||||
|
(jedes Werkzeug gilt hier als "erfüllt") — sobald Fristen feststehen,
|
||||||
|
muss die Filterfunktion entsprechend erweitert werden.
|
||||||
|
|
||||||
|
## 5. Fallback, wenn keine Datenklasse zutrifft
|
||||||
|
|
||||||
|
Wenn ein Antrag bei allen B-Fragen "nein" beantwortet — auch bei B7
|
||||||
|
("nur allgemein zugängliche/erfundene Inhalte") — trifft laut Tabelle
|
||||||
|
keine Stufe zu (ein eigentlich widersprüchlicher Zustand: irgendeine
|
||||||
|
Kategorie sollte immer zutreffen). `internal/rules.EvaluateDatenklasse`
|
||||||
|
nimmt in diesem Fall konservativ `intern` an, nicht `oeffentlich` — im
|
||||||
|
Zweifel mehr Schutzanforderungen, nicht weniger. **Nicht fachlich
|
||||||
|
bestätigt**, nur eine sichere Standardannahme.
|
||||||
|
|
||||||
|
## 6. Welche Anforderungen werden hart gegen ein Werkzeug gefiltert?
|
||||||
|
|
||||||
|
`internal/rules.ErfuelltAnforderung` filtert aktuell hart nur
|
||||||
|
`avv_erforderlich`, `eu_verarbeitung` und `kein_training_auf_eingabe` —
|
||||||
|
das sind technische Eigenschaften eines Werkzeugs
|
||||||
|
(`werkzeug.avv_verfuegbar`, `verarbeitungsort`, `training_standard`).
|
||||||
|
`menschliche_aufsicht`, `kennzeichnungspflicht` und `dsfa_erforderlich`
|
||||||
|
betreffen dagegen einen Prozess rund um die Nutzung, nicht das Werkzeug
|
||||||
|
selbst — ein Werkzeug wird deswegen nicht aussortiert, das wird
|
||||||
|
stattdessen als Auflage vermerkt (geplant für Schritt 4/5). **Diese
|
||||||
|
Aufteilung ist eine Annahme von Schritt 3, nicht ausdrücklich in der
|
||||||
|
Spezifikation so festgelegt** — vor Schritt 4 gegenprüfen, ob das der
|
||||||
|
gewünschten Produktlogik entspricht.
|
||||||
|
|||||||
Reference in New Issue
Block a user