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) }