feat: add rules engine (internal/rules) with first two disclosure rules
internal/rules implements Stufe 2 from the core principle: the LLM extracts facts, this deterministic engine judges them against versioned YAML rules. Facts/Condition/Rule/Finding types, a loader that refuses to load on a missing id/version or a duplicate rule id rather than silently skipping a bad file, and Evaluate() matching facts against rules. Two real rules grounded in verified research (see rules/OPEN.md for the open questions that surfaced along the way): - WK-001: no disclosure at all despite consideration (§ 5a Abs. 4 UWG, § 22 Abs. 1 MStV) - WK-004: disclosure present but hidden behind a "mehr anzeigen" cut (§ 5a Abs. 4 UWG, Leitfaden der Medienanstalten, LG Köln 12.05.2026) The two are deliberately disjoint (WK-004 requires disclosure_present= true) so a post with no disclosure at all doesn't double-fire both rules. Golden suite in testdata/golden/ covers both rules plus two clean cases; it's this suite, not the UI, that's the actual asset per CLAUDE.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
30
internal/rules/evaluate.go
Normal file
30
internal/rules/evaluate.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package rules
|
||||
|
||||
// Finding ist das Ergebnis einer einzelnen zutreffenden Regel.
|
||||
type Finding struct {
|
||||
RuleID string
|
||||
RuleVersion int
|
||||
Severity Severity
|
||||
Title string
|
||||
Fix string
|
||||
Sources []string
|
||||
}
|
||||
|
||||
// Evaluate prüft alle Regeln gegen f und liefert ein Finding für jede
|
||||
// zutreffende Regel. Reihenfolge folgt der Reihenfolge von rules.
|
||||
func Evaluate(rules []Rule, f Facts) []Finding {
|
||||
var findings []Finding
|
||||
for _, r := range rules {
|
||||
if r.Condition.Matches(f) {
|
||||
findings = append(findings, Finding{
|
||||
RuleID: r.ID,
|
||||
RuleVersion: r.Version,
|
||||
Severity: r.Severity,
|
||||
Title: r.Title,
|
||||
Fix: r.Fix,
|
||||
Sources: r.Sources,
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
23
internal/rules/facts.go
Normal file
23
internal/rules/facts.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package rules
|
||||
|
||||
// Consideration ist die Gegenleistung für einen Beitrag, wie sie die
|
||||
// Extraktion (Stufe 1) liefert.
|
||||
type Consideration string
|
||||
|
||||
const (
|
||||
ConsiderationPaid Consideration = "bezahlt"
|
||||
ConsiderationInKind Consideration = "sachbezug"
|
||||
ConsiderationNone Consideration = "keine"
|
||||
ConsiderationUnclear Consideration = "unklar"
|
||||
)
|
||||
|
||||
// Facts sind die Fakten aus der Extraktion (Stufe 1), auf denen die
|
||||
// Regelauswertung (Stufe 2) urteilt. Die Extraktion liefert diese Werte,
|
||||
// sie bewertet sie nicht — das Urteil fällt ausschließlich das Regelwerk.
|
||||
type Facts struct {
|
||||
Platform string `json:"platform"`
|
||||
Consideration Consideration `json:"consideration"`
|
||||
DisclosurePresent bool `json:"disclosure_present"`
|
||||
DisclosureWording string `json:"disclosure_wording"`
|
||||
DisclosureBeforeCut bool `json:"disclosure_before_cut"`
|
||||
}
|
||||
101
internal/rules/golden_test.go
Normal file
101
internal/rules/golden_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package rules_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
// goldenCase spiegelt eine Datei aus testdata/golden/: die extrahierten
|
||||
// Fakten eines Beispielbeitrags plus die Findings, die das Regelwerk
|
||||
// dafür liefern muss. Das ist das eigentliche Asset des Projekts, nicht
|
||||
// die UI — siehe CLAUDE.md.
|
||||
type goldenCase struct {
|
||||
Name string `json:"name"`
|
||||
Facts rules.Facts `json:"facts"`
|
||||
ExpectedFindings []goldenFinding `json:"expected_findings"`
|
||||
}
|
||||
|
||||
type goldenFinding struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
RuleVersion int `json:"rule_version"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
const (
|
||||
rulesDir = "../../rules"
|
||||
goldenDir = "../../testdata/golden"
|
||||
)
|
||||
|
||||
func TestGolden(t *testing.T) {
|
||||
ruleSet, err := rules.Load(os.DirFS(rulesDir))
|
||||
if err != nil {
|
||||
t.Fatalf("Load rules: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(goldenDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read golden dir: %v", err)
|
||||
}
|
||||
|
||||
found := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
entry := entry
|
||||
t.Run(entry.Name(), func(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join(goldenDir, entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", entry.Name(), err)
|
||||
}
|
||||
var gc goldenCase
|
||||
if err := json.Unmarshal(data, &gc); err != nil {
|
||||
t.Fatalf("parse %s: %v", entry.Name(), err)
|
||||
}
|
||||
|
||||
got := rules.Evaluate(ruleSet, gc.Facts)
|
||||
|
||||
gotKeys := make([]string, 0, len(got))
|
||||
for _, f := range got {
|
||||
gotKeys = append(gotKeys, findingKey(f.RuleID, f.RuleVersion, string(f.Severity)))
|
||||
}
|
||||
wantKeys := make([]string, 0, len(gc.ExpectedFindings))
|
||||
for _, ef := range gc.ExpectedFindings {
|
||||
wantKeys = append(wantKeys, findingKey(ef.RuleID, ef.RuleVersion, ef.Severity))
|
||||
}
|
||||
sort.Strings(gotKeys)
|
||||
sort.Strings(wantKeys)
|
||||
|
||||
if !equalStrings(gotKeys, wantKeys) {
|
||||
t.Fatalf("%s: got findings %v, want %v", gc.Name, gotKeys, wantKeys)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if found == 0 {
|
||||
t.Fatal("no golden cases found in " + goldenDir)
|
||||
}
|
||||
}
|
||||
|
||||
func findingKey(ruleID string, version int, severity string) string {
|
||||
return ruleID + "/" + strconv.Itoa(version) + "/" + severity
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
49
internal/rules/loader.go
Normal file
49
internal/rules/loader.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Load liest alle *.yaml-Dateien aus fsys (nicht rekursiv) und parst sie
|
||||
// als Regeln. Ein Fehler in einer Datei (Parse-Fehler, fehlende ID/
|
||||
// Version) bricht das Laden komplett ab, statt die Datei stillschweigend
|
||||
// zu überspringen — ein halb geladenes Regelwerk ist gefährlicher als
|
||||
// ein Start, der mit einem klaren Fehler abbricht.
|
||||
func Load(fsys fs.FS) ([]Rule, error) {
|
||||
entries, err := fs.ReadDir(fsys, ".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rules: read dir: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var result []Rule
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
|
||||
continue
|
||||
}
|
||||
data, err := fs.ReadFile(fsys, entry.Name())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rules: read %s: %w", entry.Name(), err)
|
||||
}
|
||||
var r Rule
|
||||
if err := yaml.Unmarshal(data, &r); err != nil {
|
||||
return nil, fmt.Errorf("rules: parse %s: %w", entry.Name(), err)
|
||||
}
|
||||
if r.ID == "" {
|
||||
return nil, fmt.Errorf("rules: %s: missing id", entry.Name())
|
||||
}
|
||||
if r.Version == 0 {
|
||||
return nil, fmt.Errorf("rules: %s: missing version", entry.Name())
|
||||
}
|
||||
if seen[r.ID] {
|
||||
return nil, fmt.Errorf("rules: %s: duplicate rule id %s", entry.Name(), r.ID)
|
||||
}
|
||||
seen[r.ID] = true
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
54
internal/rules/rule.go
Normal file
54
internal/rules/rule.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package rules
|
||||
|
||||
// Severity ist die Schwere eines Findings.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
SeverityLow Severity = "niedrig"
|
||||
SeverityMedium Severity = "mittel"
|
||||
SeverityHigh Severity = "hoch"
|
||||
)
|
||||
|
||||
// Rule ist eine versionierte Regel aus einer YAML-Datei in rules/.
|
||||
// Regel-IDs werden nie umbenannt oder wiederverwendet — Änderungen an
|
||||
// einer Regel erhöhen die Version.
|
||||
type Rule struct {
|
||||
ID string `yaml:"id"`
|
||||
Version int `yaml:"version"`
|
||||
Title string `yaml:"titel"`
|
||||
Condition Condition `yaml:"bedingung"`
|
||||
Severity Severity `yaml:"schwere"`
|
||||
Sources []string `yaml:"fundstelle"`
|
||||
Fix string `yaml:"korrektur"`
|
||||
}
|
||||
|
||||
// Condition ist eine flache UND-Bedingung über Facts. Ein nil/leeres
|
||||
// Feld bedeutet "keine Einschränkung durch dieses Feld".
|
||||
type Condition struct {
|
||||
Consideration []Consideration `yaml:"gegenleistung,omitempty"`
|
||||
DisclosurePresent *bool `yaml:"kennzeichnung_vorhanden,omitempty"`
|
||||
DisclosureBeforeCut *bool `yaml:"kennzeichnung_vor_kuerzung,omitempty"`
|
||||
}
|
||||
|
||||
// Matches prüft, ob f alle gesetzten Bedingungsfelder erfüllt.
|
||||
func (c Condition) Matches(f Facts) bool {
|
||||
if len(c.Consideration) > 0 {
|
||||
found := false
|
||||
for _, allowed := range c.Consideration {
|
||||
if allowed == f.Consideration {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if c.DisclosurePresent != nil && *c.DisclosurePresent != f.DisclosurePresent {
|
||||
return false
|
||||
}
|
||||
if c.DisclosureBeforeCut != nil && *c.DisclosureBeforeCut != f.DisclosureBeforeCut {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
105
internal/rules/rule_test.go
Normal file
105
internal/rules/rule_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package rules_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
func TestLoadRejectsMissingID(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"bad.yaml": &fstest.MapFile{Data: []byte("version: 1\ntitel: x\nschwere: hoch\n")},
|
||||
}
|
||||
if _, err := rules.Load(fsys); err == nil {
|
||||
t.Fatal("expected error for rule without id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingVersion(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"bad.yaml": &fstest.MapFile{Data: []byte("id: WK-999\ntitel: x\nschwere: hoch\n")},
|
||||
}
|
||||
if _, err := rules.Load(fsys); err == nil {
|
||||
t.Fatal("expected error for rule without version, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsDuplicateID(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\ntitel: x\nschwere: hoch\n")},
|
||||
"b.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 2\ntitel: y\nschwere: hoch\n")},
|
||||
}
|
||||
if _, err := rules.Load(fsys); err == nil {
|
||||
t.Fatal("expected error for duplicate rule id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIgnoresNonYAMLFiles(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\ntitel: x\nschwere: hoch\n")},
|
||||
"README.md": &fstest.MapFile{Data: []byte("not a rule")},
|
||||
}
|
||||
got, err := rules.Load(fsys)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 rule, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionMatches(t *testing.T) {
|
||||
yes := true
|
||||
no := false
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cond rules.Condition
|
||||
fact rules.Facts
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "consideration list matches",
|
||||
cond: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
|
||||
fact: rules.Facts{Consideration: rules.ConsiderationPaid},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "consideration list does not match",
|
||||
cond: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
|
||||
fact: rules.Facts{Consideration: rules.ConsiderationNone},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "disclosure present must match",
|
||||
cond: rules.Condition{DisclosurePresent: &no},
|
||||
fact: rules.Facts{DisclosurePresent: true},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unset fields impose no constraint",
|
||||
cond: rules.Condition{},
|
||||
fact: rules.Facts{Consideration: rules.ConsiderationUnclear},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "all constraints must hold (AND)",
|
||||
cond: rules.Condition{
|
||||
Consideration: []rules.Consideration{rules.ConsiderationPaid},
|
||||
DisclosurePresent: &yes,
|
||||
DisclosureBeforeCut: &no,
|
||||
},
|
||||
fact: rules.Facts{Consideration: rules.ConsiderationPaid, DisclosurePresent: true, DisclosureBeforeCut: false},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.cond.Matches(tc.fact); got != tc.want {
|
||||
t.Fatalf("Matches() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user