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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id: WK-001
|
||||
version: 1
|
||||
land: DE
|
||||
titel: Keine Kennzeichnung trotz Gegenleistung
|
||||
bedingung:
|
||||
gegenleistung: [bezahlt, sachbezug]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "Organischer Post ohne Gegenleistung, keine Kennzeichnung noetig",
|
||||
"facts": {
|
||||
"platform": "instagram",
|
||||
"jurisdiction": "DE",
|
||||
"consideration": "keine",
|
||||
"disclosure_present": false,
|
||||
"disclosure_wording": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
testdata/golden/paid-disclosure-visible.json
vendored
1
testdata/golden/paid-disclosure-visible.json
vendored
@@ -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",
|
||||
|
||||
1
testdata/golden/paid-no-disclosure.json
vendored
1
testdata/golden/paid-no-disclosure.json
vendored
@@ -2,6 +2,7 @@
|
||||
"name": "Bezahlter Post ohne jede Kennzeichnung",
|
||||
"facts": {
|
||||
"platform": "instagram",
|
||||
"jurisdiction": "DE",
|
||||
"consideration": "bezahlt",
|
||||
"disclosure_present": false,
|
||||
"disclosure_wording": "",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "Gegenleistung unklar - Rueckfrage statt Bewertung",
|
||||
"facts": {
|
||||
"platform": "instagram",
|
||||
"jurisdiction": "DE",
|
||||
"consideration": "unklar",
|
||||
"disclosure_present": false,
|
||||
"disclosure_wording": "",
|
||||
|
||||
Reference in New Issue
Block a user