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