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>
265 lines
9.1 KiB
Go
265 lines
9.1 KiB
Go
package rules
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"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"
|
|
}
|
|
|
|
// 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"
|
|
// 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) 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) {
|
|
stTreffer = append(stTreffer, ausloeser)
|
|
}
|
|
}
|
|
if len(stTreffer) > 0 && (gewinner == nil || st.Rang > gewinner.Rang) {
|
|
gewinner = st
|
|
treffer = stTreffer
|
|
}
|
|
}
|
|
if gewinner == nil {
|
|
return DatenklasseErgebnis{ID: fallback, Herleitung: "keine Kategorie aus Abschnitt B traf eindeutig zu (konservativer Standardwert)"}
|
|
}
|
|
return DatenklasseErgebnis{ID: gewinner.ID, Herleitung: "ausgelöst durch " + strings.Join(treffer, ", ")}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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) (EinstufungErgebnis, error) {
|
|
for _, st := range regelwerk.Stufen {
|
|
if len(st.Varianten) == 0 {
|
|
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 EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: formatVariante(variante)}, nil
|
|
}
|
|
}
|
|
}
|
|
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"
|
|
// erfolgt sofortige Ablehnung, keine Werkzeugsuche.
|
|
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) []AnforderungErgebnis {
|
|
var out []AnforderungErgebnis
|
|
for _, a := range regelwerk.Anforderungen {
|
|
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
|
|
}
|
|
|
|
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
|
|
// 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)
|
|
}
|