From 15bf277bee9eed0c2e405f0f9bd5b8c270b5dac3 Mon Sep 17 00:00:00 2001 From: noroot Date: Thu, 27 Aug 2026 13:47:08 +0200 Subject: [PATCH] feat: add jurisdiction field to rules, facts and extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/extract/client.go | 9 +++-- internal/extract/client_test.go | 2 ++ internal/rules/evaluate.go | 3 ++ internal/rules/facts.go | 6 ++++ internal/rules/loader.go | 3 ++ internal/rules/rule.go | 22 ++++++++---- internal/rules/rule_test.go | 34 +++++++++++++++++-- rules/WK-001.yaml | 1 + rules/WK-004.yaml | 1 + testdata/golden/organic-no-consideration.json | 1 + .../golden/paid-disclosure-hidden-by-cut.json | 1 + testdata/golden/paid-disclosure-visible.json | 1 + testdata/golden/paid-no-disclosure.json | 1 + ...ear-consideration-needs-clarification.json | 1 + 14 files changed, 73 insertions(+), 13 deletions(-) diff --git a/internal/extract/client.go b/internal/extract/client.go index 6b6b1fc..ded8d60 100644 --- a/internal/extract/client.go +++ b/internal/extract/client.go @@ -34,11 +34,13 @@ Du triffst KEINE rechtliche Bewertung und nennst KEINE Gesetze, Paragraphen oder Ist ein Fakt nicht sicher aus Caption oder Bild zu bestimmen, wähle den dafür vorgesehenen Unsicherheitswert (z. B. "unklar"). Rate niemals.` -// Input ist, was Stufe 1 zur Extraktion braucht. Platform kommt vom -// Aufrufer (der Nutzer wählt die Plattform beim Einreichen) statt vom -// Modell erraten zu werden. +// Input ist, was Stufe 1 zur Extraktion braucht. Platform und +// Jurisdiction kommen vom Aufrufer (der Nutzer wählt Plattform und +// Rechtsordnung beim Einreichen) statt vom Modell erraten zu werden — +// aus Caption/Bild lässt sich keine Rechtsordnung ablesen. type Input struct { Platform string + Jurisdiction string Caption string ImageMediaType string // z. B. "image/jpeg", "image/png" ImageData []byte @@ -182,6 +184,7 @@ func (c *Client) Extract(ctx context.Context, in Input) (rules.Facts, error) { return rules.Facts{ Platform: in.Platform, + Jurisdiction: in.Jurisdiction, Consideration: consideration, DisclosurePresent: args.DisclosurePresent, DisclosureWording: args.DisclosureWording, diff --git a/internal/extract/client_test.go b/internal/extract/client_test.go index e2a639e..236af63 100644 --- a/internal/extract/client_test.go +++ b/internal/extract/client_test.go @@ -86,6 +86,7 @@ func TestExtractSuccess(t *testing.T) { got, err := c.Extract(context.Background(), extract.Input{ Platform: "instagram", + Jurisdiction: "DE", Caption: "Schaut euch dieses Produkt an! Werbung wegen ...", ImageMediaType: "image/jpeg", ImageData: []byte("fake-jpeg-bytes"), @@ -96,6 +97,7 @@ func TestExtractSuccess(t *testing.T) { want := rules.Facts{ Platform: "instagram", + Jurisdiction: "DE", Consideration: rules.ConsiderationPaid, DisclosurePresent: true, DisclosureWording: "Werbung", diff --git a/internal/rules/evaluate.go b/internal/rules/evaluate.go index a245494..e19bd42 100644 --- a/internal/rules/evaluate.go +++ b/internal/rules/evaluate.go @@ -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, diff --git a/internal/rules/facts.go b/internal/rules/facts.go index c6c3b51..6219e89 100644 --- a/internal/rules/facts.go +++ b/internal/rules/facts.go @@ -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"` diff --git a/internal/rules/loader.go b/internal/rules/loader.go index 7b80a86..7f2cc3c 100644 --- a/internal/rules/loader.go +++ b/internal/rules/loader.go @@ -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) } diff --git a/internal/rules/rule.go b/internal/rules/rule.go index e7f7401..4fdb7a2 100644 --- a/internal/rules/rule.go +++ b/internal/rules/rule.go @@ -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 diff --git a/internal/rules/rule_test.go b/internal/rules/rule_test.go index 4c158c1..6a3e50b 100644 --- a/internal/rules/rule_test.go +++ b/internal/rules/rule_test.go @@ -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) + } +} diff --git a/rules/WK-001.yaml b/rules/WK-001.yaml index 88cebd1..8dde9a2 100644 --- a/rules/WK-001.yaml +++ b/rules/WK-001.yaml @@ -1,5 +1,6 @@ id: WK-001 version: 1 +land: DE titel: Keine Kennzeichnung trotz Gegenleistung bedingung: gegenleistung: [bezahlt, sachbezug] diff --git a/rules/WK-004.yaml b/rules/WK-004.yaml index 732e17d..b4504e4 100644 --- a/rules/WK-004.yaml +++ b/rules/WK-004.yaml @@ -1,5 +1,6 @@ id: WK-004 version: 1 +land: DE titel: Kennzeichnung nicht auf den ersten Blick erkennbar (Kürzung durch "mehr anzeigen") bedingung: gegenleistung: [bezahlt, sachbezug] diff --git a/testdata/golden/organic-no-consideration.json b/testdata/golden/organic-no-consideration.json index b2db8a8..8335696 100644 --- a/testdata/golden/organic-no-consideration.json +++ b/testdata/golden/organic-no-consideration.json @@ -2,6 +2,7 @@ "name": "Organischer Post ohne Gegenleistung, keine Kennzeichnung noetig", "facts": { "platform": "instagram", + "jurisdiction": "DE", "consideration": "keine", "disclosure_present": false, "disclosure_wording": "", diff --git a/testdata/golden/paid-disclosure-hidden-by-cut.json b/testdata/golden/paid-disclosure-hidden-by-cut.json index f1de2ea..9dbda8c 100644 --- a/testdata/golden/paid-disclosure-hidden-by-cut.json +++ b/testdata/golden/paid-disclosure-hidden-by-cut.json @@ -2,6 +2,7 @@ "name": "Bezahlter Post, Kennzeichnung vorhanden aber hinter 'mehr anzeigen' versteckt", "facts": { "platform": "instagram", + "jurisdiction": "DE", "consideration": "bezahlt", "disclosure_present": true, "disclosure_wording": "Werbung", diff --git a/testdata/golden/paid-disclosure-visible.json b/testdata/golden/paid-disclosure-visible.json index d894d97..c7b4087 100644 --- a/testdata/golden/paid-disclosure-visible.json +++ b/testdata/golden/paid-disclosure-visible.json @@ -2,6 +2,7 @@ "name": "Bezahlter Post, Kennzeichnung korrekt vor der Kürzung sichtbar", "facts": { "platform": "tiktok", + "jurisdiction": "DE", "consideration": "sachbezug", "disclosure_present": true, "disclosure_wording": "Werbung", diff --git a/testdata/golden/paid-no-disclosure.json b/testdata/golden/paid-no-disclosure.json index 35f07ac..284135c 100644 --- a/testdata/golden/paid-no-disclosure.json +++ b/testdata/golden/paid-no-disclosure.json @@ -2,6 +2,7 @@ "name": "Bezahlter Post ohne jede Kennzeichnung", "facts": { "platform": "instagram", + "jurisdiction": "DE", "consideration": "bezahlt", "disclosure_present": false, "disclosure_wording": "", diff --git a/testdata/golden/unclear-consideration-needs-clarification.json b/testdata/golden/unclear-consideration-needs-clarification.json index 8994cef..cd4738f 100644 --- a/testdata/golden/unclear-consideration-needs-clarification.json +++ b/testdata/golden/unclear-consideration-needs-clarification.json @@ -2,6 +2,7 @@ "name": "Gegenleistung unklar - Rueckfrage statt Bewertung", "facts": { "platform": "instagram", + "jurisdiction": "DE", "consideration": "unklar", "disclosure_present": false, "disclosure_wording": "",