feat: Ergebnisdarstellung mit Herleitung (Schritt 4 der Baureihenfolge)

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>
This commit is contained in:
noroot
2026-08-29 10:22:24 +02:00
parent b1b121bb23
commit e968cf9761
13 changed files with 835 additions and 54 deletions

View File

@@ -3,6 +3,7 @@ package rules
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
@@ -33,6 +34,14 @@ func (a Antworten) istJaOderUnsicher(key string) bool {
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"
@@ -40,24 +49,27 @@ func (a Antworten) istJaOderUnsicher(key string) bool {
// 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 {
func EvaluateDatenklasse(regelwerk DatenklasseRegelwerk, antworten Antworten) DatenklasseErgebnis {
const fallback = "intern"
var gewinner *DatenklasseStufe
var treffer []string
for i := range regelwerk.Stufen {
st := &regelwerk.Stufen[i]
var stTreffer []string
for _, ausloeser := range st.Ausloeser {
if antworten.istJaOderUnsicher(ausloeser) {
if gewinner == nil || st.Rang > gewinner.Rang {
gewinner = st
}
break
stTreffer = append(stTreffer, ausloeser)
}
}
if len(stTreffer) > 0 && (gewinner == nil || st.Rang > gewinner.Rang) {
gewinner = st
treffer = stTreffer
}
}
if gewinner == nil {
return fallback
return DatenklasseErgebnis{ID: fallback, Herleitung: "keine Kategorie aus Abschnitt B traf eindeutig zu (konservativer Standardwert)"}
}
return gewinner.ID
return DatenklasseErgebnis{ID: gewinner.ID, Herleitung: "ausgelöst durch " + strings.Join(treffer, ", ")}
}
// variantePasst prüft, ob alle Schlüssel-Werte-Paare einer Variante zu
@@ -86,24 +98,46 @@ func variantePasst(variante EinstufungVariante, antworten Antworten) bool {
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) (string, error) {
func EvaluateEinstufung(regelwerk EinstufungRegelwerk, antworten Antworten) (EinstufungErgebnis, error) {
for _, st := range regelwerk.Stufen {
if len(st.Varianten) == 0 {
return st.ID, nil // Auffangregel, z. B. "minimal"
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 st.ID, nil
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: formatVariante(variante)}, nil
}
}
}
return "", fmt.Errorf("rules: keine einstufung trifft zu und keine auffangregel definiert")
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"
@@ -112,13 +146,27 @@ 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) []string {
var out []string
func DeriveAnforderungen(regelwerk AnforderungsRegelwerk, datenklasseID, einstufungID string) []AnforderungErgebnis {
var out []AnforderungErgebnis
for _, a := range regelwerk.Anforderungen {
if containsString(a.AusDatenklassen, datenklasseID) || containsString(a.AusEinstufungen, einstufungID) {
out = append(out, a.ID)
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
@@ -200,8 +248,17 @@ func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string) (zu
return zulaessig, ausgeschlossen
}
// String liefert eine lesbare Begründung, z. B. für die spätere
// Ergebnisdarstellung (Schritt 4).
// 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)
}

View File

@@ -28,8 +28,8 @@ 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)
if got.ID != "oeffentlich" {
t.Fatalf("EvaluateDatenklasse.ID = %q, want oeffentlich", got.ID)
}
}
@@ -39,8 +39,8 @@ func TestEvaluateDatenklasseHighestWins(t *testing.T) {
// 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)
if got.ID != "besondere_kategorie" {
t.Fatalf("EvaluateDatenklasse.ID = %q, want besondere_kategorie", got.ID)
}
}
@@ -48,8 +48,8 @@ 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)
if got.ID != "berufsgeheimnis" {
t.Fatalf("EvaluateDatenklasse.ID = %q, want berufsgeheimnis (unsicher zaehlt wie ja)", got.ID)
}
}
@@ -57,8 +57,8 @@ 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)
if got.ID != "intern" {
t.Fatalf("EvaluateDatenklasse.ID = %q, want intern (konservativer Fallback)", got.ID)
}
}
@@ -69,10 +69,10 @@ func TestEvaluateEinstufungVerboten(t *testing.T) {
if err != nil {
t.Fatalf("EvaluateEinstufung: %v", err)
}
if got != "verboten" {
t.Fatalf("EvaluateEinstufung = %q, want verboten", got)
if got.ID != "verboten" {
t.Fatalf("EvaluateEinstufung.ID = %q, want verboten", got.ID)
}
if !rules.IstVerboten(got) {
if !rules.IstVerboten(got.ID) {
t.Error("IstVerboten sollte true liefern")
}
}
@@ -84,8 +84,8 @@ func TestEvaluateEinstufungHochrisiko(t *testing.T) {
if err != nil {
t.Fatalf("EvaluateEinstufung: %v", err)
}
if got != "hochrisiko" {
t.Fatalf("EvaluateEinstufung = %q, want hochrisiko", got)
if got.ID != "hochrisiko" {
t.Fatalf("EvaluateEinstufung.ID = %q, want hochrisiko", got.ID)
}
}
@@ -96,8 +96,8 @@ func TestEvaluateEinstufungTransparenzpflicht(t *testing.T) {
if err != nil {
t.Fatalf("EvaluateEinstufung: %v", err)
}
if got != "transparenzpflicht" {
t.Fatalf("EvaluateEinstufung = %q, want transparenzpflicht", got)
if got.ID != "transparenzpflicht" {
t.Fatalf("EvaluateEinstufung.ID = %q, want transparenzpflicht", got.ID)
}
}
@@ -108,10 +108,10 @@ func TestEvaluateEinstufungMinimalFallback(t *testing.T) {
if err != nil {
t.Fatalf("EvaluateEinstufung: %v", err)
}
if got != "minimal" {
t.Fatalf("EvaluateEinstufung = %q, want minimal", got)
if got.ID != "minimal" {
t.Fatalf("EvaluateEinstufung.ID = %q, want minimal", got.ID)
}
if rules.IstVerboten(got) {
if rules.IstVerboten(got.ID) {
t.Error("IstVerboten sollte fuer minimal false liefern")
}
}
@@ -121,8 +121,11 @@ func TestDeriveAnforderungenPersonenbezogen(t *testing.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 _, a := range got {
gotSet[a.ID] = true
if a.Herleitung == "" {
t.Errorf("Anforderung %q hat keine Herleitung", a.ID)
}
}
for id := range want {
if !gotSet[id] {
@@ -138,8 +141,8 @@ 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
for _, a := range got {
gotSet[a.ID] = true
}
if !gotSet["menschliche_aufsicht"] || !gotSet["dsfa_erforderlich"] {
t.Errorf("expected menschliche_aufsicht und dsfa_erforderlich fuer hochrisiko, got %v", got)