feat: add jurisdiction field to rules, facts and extraction

Rule now carries a required Jurisdiction (land) field, and Facts a
matching Jurisdiction supplied by the caller (like Platform — no
legal jurisdiction can be read off a caption or image, so the model
never guesses it). Evaluate() only lets a rule fire when its
jurisdiction matches the facts' jurisdiction.

This is the structural half of "deutsche Rechtslage zuerst, Struktur
für Österreich und Schweiz vorgesehen": a future AT/CH rule set can be
added as plain new YAML files without touching existing DE rules, but
no AT/CH content is added now — that needs its own legal research
first, same as WK-001/WK-004 needed for Germany.

WK-001 and WK-004 are tagged land: DE, all golden fixtures carry
jurisdiction: DE, and extract.Input passes Jurisdiction through
unchanged into the returned Facts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-27 13:47:08 +02:00
parent da91c3e2c1
commit 15bf277bee
14 changed files with 73 additions and 13 deletions

View File

@@ -24,6 +24,9 @@ func Evaluate(rules []Rule, f Facts) (findings []Finding, needsClarification boo
}
for _, r := range rules {
if r.Jurisdiction != f.Jurisdiction {
continue
}
if r.Condition.Matches(f) {
findings = append(findings, Finding{
RuleID: r.ID,

View File

@@ -14,8 +14,14 @@ const (
// 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.
//
// Jurisdiction ist keine vom Modell extrahierte Tatsache (aus Caption/
// Bild lässt sich keine Rechtsordnung ablesen), sondern kommt vom
// Aufrufer — analog zu Platform. Aktuell ist "DE" der einzig unterstützte
// Wert; siehe Rule.Jurisdiction.
type Facts struct {
Platform string `json:"platform"`
Jurisdiction string `json:"jurisdiction"`
Consideration Consideration `json:"consideration"`
DisclosurePresent bool `json:"disclosure_present"`
DisclosureWording string `json:"disclosure_wording"`

View File

@@ -39,6 +39,9 @@ func Load(fsys fs.FS) ([]Rule, error) {
if r.Version == 0 {
return nil, fmt.Errorf("rules: %s: missing version", entry.Name())
}
if r.Jurisdiction == "" {
return nil, fmt.Errorf("rules: %s: missing land (jurisdiction)", entry.Name())
}
if seen[r.ID] {
return nil, fmt.Errorf("rules: %s: duplicate rule id %s", entry.Name(), r.ID)
}

View File

@@ -12,14 +12,22 @@ const (
// 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.
//
// Jurisdiction ordnet eine Regel genau einer Rechtsordnung zu (aktuell
// nur "DE" — deutsche Rechtslage zuerst). Damit lassen sich künftige
// AT/CH-Regeln als zusätzliche Dateien ergänzen, ohne bestehende Regeln
// anzufassen: eine Regel gilt nie für mehrere Rechtsordnungen gleichzeitig,
// auch wenn sich Gesetzestexte ähneln — jede Rechtsordnung bekommt ihre
// eigene, einzeln geprüfte Fundstelle.
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"`
ID string `yaml:"id"`
Version int `yaml:"version"`
Jurisdiction string `yaml:"land"`
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

View File

@@ -25,10 +25,19 @@ func TestLoadRejectsMissingVersion(t *testing.T) {
}
}
func TestLoadRejectsMissingJurisdiction(t *testing.T) {
fsys := fstest.MapFS{
"bad.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\ntitel: x\nschwere: hoch\n")},
}
if _, err := rules.Load(fsys); err == nil {
t.Fatal("expected error for rule without land (jurisdiction), 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")},
"a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\nland: DE\ntitel: x\nschwere: hoch\n")},
"b.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 2\nland: DE\ntitel: y\nschwere: hoch\n")},
}
if _, err := rules.Load(fsys); err == nil {
t.Fatal("expected error for duplicate rule id, got nil")
@@ -37,7 +46,7 @@ func TestLoadRejectsDuplicateID(t *testing.T) {
func TestLoadIgnoresNonYAMLFiles(t *testing.T) {
fsys := fstest.MapFS{
"a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\ntitel: x\nschwere: hoch\n")},
"a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\nland: DE\ntitel: x\nschwere: hoch\n")},
"README.md": &fstest.MapFile{Data: []byte("not a rule")},
}
got, err := rules.Load(fsys)
@@ -103,3 +112,22 @@ func TestConditionMatches(t *testing.T) {
})
}
}
func TestEvaluateFiltersByJurisdiction(t *testing.T) {
rule := rules.Rule{
ID: "WK-TEST",
Version: 1,
Jurisdiction: "AT",
Condition: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
Severity: rules.SeverityHigh,
}
fact := rules.Facts{Jurisdiction: "DE", Consideration: rules.ConsiderationPaid}
findings, needsClarification := rules.Evaluate([]rules.Rule{rule}, fact)
if needsClarification {
t.Fatal("needsClarification = true, want false")
}
if len(findings) != 0 {
t.Fatalf("expected no findings for a rule from a different jurisdiction, got %v", findings)
}
}