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).
208 lines
6.7 KiB
Go
208 lines
6.7 KiB
Go
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, ", "))
|
|
}
|