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.`
|
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
|
// Input ist, was Stufe 1 zur Extraktion braucht. Platform und
|
||||||
// Aufrufer (der Nutzer wählt die Plattform beim Einreichen) statt vom
|
// Jurisdiction kommen vom Aufrufer (der Nutzer wählt Plattform und
|
||||||
// Modell erraten zu werden.
|
// Rechtsordnung beim Einreichen) statt vom Modell erraten zu werden —
|
||||||
|
// aus Caption/Bild lässt sich keine Rechtsordnung ablesen.
|
||||||
type Input struct {
|
type Input struct {
|
||||||
Platform string
|
Platform string
|
||||||
|
Jurisdiction string
|
||||||
Caption string
|
Caption string
|
||||||
ImageMediaType string // z. B. "image/jpeg", "image/png"
|
ImageMediaType string // z. B. "image/jpeg", "image/png"
|
||||||
ImageData []byte
|
ImageData []byte
|
||||||
@@ -182,6 +184,7 @@ func (c *Client) Extract(ctx context.Context, in Input) (rules.Facts, error) {
|
|||||||
|
|
||||||
return rules.Facts{
|
return rules.Facts{
|
||||||
Platform: in.Platform,
|
Platform: in.Platform,
|
||||||
|
Jurisdiction: in.Jurisdiction,
|
||||||
Consideration: consideration,
|
Consideration: consideration,
|
||||||
DisclosurePresent: args.DisclosurePresent,
|
DisclosurePresent: args.DisclosurePresent,
|
||||||
DisclosureWording: args.DisclosureWording,
|
DisclosureWording: args.DisclosureWording,
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ func TestExtractSuccess(t *testing.T) {
|
|||||||
|
|
||||||
got, err := c.Extract(context.Background(), extract.Input{
|
got, err := c.Extract(context.Background(), extract.Input{
|
||||||
Platform: "instagram",
|
Platform: "instagram",
|
||||||
|
Jurisdiction: "DE",
|
||||||
Caption: "Schaut euch dieses Produkt an! Werbung wegen ...",
|
Caption: "Schaut euch dieses Produkt an! Werbung wegen ...",
|
||||||
ImageMediaType: "image/jpeg",
|
ImageMediaType: "image/jpeg",
|
||||||
ImageData: []byte("fake-jpeg-bytes"),
|
ImageData: []byte("fake-jpeg-bytes"),
|
||||||
@@ -96,6 +97,7 @@ func TestExtractSuccess(t *testing.T) {
|
|||||||
|
|
||||||
want := rules.Facts{
|
want := rules.Facts{
|
||||||
Platform: "instagram",
|
Platform: "instagram",
|
||||||
|
Jurisdiction: "DE",
|
||||||
Consideration: rules.ConsiderationPaid,
|
Consideration: rules.ConsiderationPaid,
|
||||||
DisclosurePresent: true,
|
DisclosurePresent: true,
|
||||||
DisclosureWording: "Werbung",
|
DisclosureWording: "Werbung",
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ func Evaluate(rules []Rule, f Facts) (findings []Finding, needsClarification boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
|
if r.Jurisdiction != f.Jurisdiction {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if r.Condition.Matches(f) {
|
if r.Condition.Matches(f) {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
RuleID: r.ID,
|
RuleID: r.ID,
|
||||||
|
|||||||
@@ -14,8 +14,14 @@ const (
|
|||||||
// Facts sind die Fakten aus der Extraktion (Stufe 1), auf denen die
|
// Facts sind die Fakten aus der Extraktion (Stufe 1), auf denen die
|
||||||
// Regelauswertung (Stufe 2) urteilt. Die Extraktion liefert diese Werte,
|
// Regelauswertung (Stufe 2) urteilt. Die Extraktion liefert diese Werte,
|
||||||
// sie bewertet sie nicht — das Urteil fällt ausschließlich das Regelwerk.
|
// 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 {
|
type Facts struct {
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
|
Jurisdiction string `json:"jurisdiction"`
|
||||||
Consideration Consideration `json:"consideration"`
|
Consideration Consideration `json:"consideration"`
|
||||||
DisclosurePresent bool `json:"disclosure_present"`
|
DisclosurePresent bool `json:"disclosure_present"`
|
||||||
DisclosureWording string `json:"disclosure_wording"`
|
DisclosureWording string `json:"disclosure_wording"`
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ func Load(fsys fs.FS) ([]Rule, error) {
|
|||||||
if r.Version == 0 {
|
if r.Version == 0 {
|
||||||
return nil, fmt.Errorf("rules: %s: missing version", entry.Name())
|
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] {
|
if seen[r.ID] {
|
||||||
return nil, fmt.Errorf("rules: %s: duplicate rule id %s", entry.Name(), 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/.
|
// Rule ist eine versionierte Regel aus einer YAML-Datei in rules/.
|
||||||
// Regel-IDs werden nie umbenannt oder wiederverwendet — Änderungen an
|
// Regel-IDs werden nie umbenannt oder wiederverwendet — Änderungen an
|
||||||
// einer Regel erhöhen die Version.
|
// 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 {
|
type Rule struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Version int `yaml:"version"`
|
Version int `yaml:"version"`
|
||||||
Title string `yaml:"titel"`
|
Jurisdiction string `yaml:"land"`
|
||||||
Condition Condition `yaml:"bedingung"`
|
Title string `yaml:"titel"`
|
||||||
Severity Severity `yaml:"schwere"`
|
Condition Condition `yaml:"bedingung"`
|
||||||
Sources []string `yaml:"fundstelle"`
|
Severity Severity `yaml:"schwere"`
|
||||||
Fix string `yaml:"korrektur"`
|
Sources []string `yaml:"fundstelle"`
|
||||||
|
Fix string `yaml:"korrektur"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Condition ist eine flache UND-Bedingung über Facts. Ein nil/leeres
|
// 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) {
|
func TestLoadRejectsDuplicateID(t *testing.T) {
|
||||||
fsys := fstest.MapFS{
|
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")},
|
||||||
"b.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 2\ntitel: y\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 {
|
if _, err := rules.Load(fsys); err == nil {
|
||||||
t.Fatal("expected error for duplicate rule id, got 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) {
|
func TestLoadIgnoresNonYAMLFiles(t *testing.T) {
|
||||||
fsys := fstest.MapFS{
|
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")},
|
"README.md": &fstest.MapFile{Data: []byte("not a rule")},
|
||||||
}
|
}
|
||||||
got, err := rules.Load(fsys)
|
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
|
id: WK-001
|
||||||
version: 1
|
version: 1
|
||||||
|
land: DE
|
||||||
titel: Keine Kennzeichnung trotz Gegenleistung
|
titel: Keine Kennzeichnung trotz Gegenleistung
|
||||||
bedingung:
|
bedingung:
|
||||||
gegenleistung: [bezahlt, sachbezug]
|
gegenleistung: [bezahlt, sachbezug]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
id: WK-004
|
id: WK-004
|
||||||
version: 1
|
version: 1
|
||||||
|
land: DE
|
||||||
titel: Kennzeichnung nicht auf den ersten Blick erkennbar (Kürzung durch "mehr anzeigen")
|
titel: Kennzeichnung nicht auf den ersten Blick erkennbar (Kürzung durch "mehr anzeigen")
|
||||||
bedingung:
|
bedingung:
|
||||||
gegenleistung: [bezahlt, sachbezug]
|
gegenleistung: [bezahlt, sachbezug]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "Organischer Post ohne Gegenleistung, keine Kennzeichnung noetig",
|
"name": "Organischer Post ohne Gegenleistung, keine Kennzeichnung noetig",
|
||||||
"facts": {
|
"facts": {
|
||||||
"platform": "instagram",
|
"platform": "instagram",
|
||||||
|
"jurisdiction": "DE",
|
||||||
"consideration": "keine",
|
"consideration": "keine",
|
||||||
"disclosure_present": false,
|
"disclosure_present": false,
|
||||||
"disclosure_wording": "",
|
"disclosure_wording": "",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "Bezahlter Post, Kennzeichnung vorhanden aber hinter 'mehr anzeigen' versteckt",
|
"name": "Bezahlter Post, Kennzeichnung vorhanden aber hinter 'mehr anzeigen' versteckt",
|
||||||
"facts": {
|
"facts": {
|
||||||
"platform": "instagram",
|
"platform": "instagram",
|
||||||
|
"jurisdiction": "DE",
|
||||||
"consideration": "bezahlt",
|
"consideration": "bezahlt",
|
||||||
"disclosure_present": true,
|
"disclosure_present": true,
|
||||||
"disclosure_wording": "Werbung",
|
"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",
|
"name": "Bezahlter Post, Kennzeichnung korrekt vor der Kürzung sichtbar",
|
||||||
"facts": {
|
"facts": {
|
||||||
"platform": "tiktok",
|
"platform": "tiktok",
|
||||||
|
"jurisdiction": "DE",
|
||||||
"consideration": "sachbezug",
|
"consideration": "sachbezug",
|
||||||
"disclosure_present": true,
|
"disclosure_present": true,
|
||||||
"disclosure_wording": "Werbung",
|
"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",
|
"name": "Bezahlter Post ohne jede Kennzeichnung",
|
||||||
"facts": {
|
"facts": {
|
||||||
"platform": "instagram",
|
"platform": "instagram",
|
||||||
|
"jurisdiction": "DE",
|
||||||
"consideration": "bezahlt",
|
"consideration": "bezahlt",
|
||||||
"disclosure_present": false,
|
"disclosure_present": false,
|
||||||
"disclosure_wording": "",
|
"disclosure_wording": "",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "Gegenleistung unklar - Rueckfrage statt Bewertung",
|
"name": "Gegenleistung unklar - Rueckfrage statt Bewertung",
|
||||||
"facts": {
|
"facts": {
|
||||||
"platform": "instagram",
|
"platform": "instagram",
|
||||||
|
"jurisdiction": "DE",
|
||||||
"consideration": "unklar",
|
"consideration": "unklar",
|
||||||
"disclosure_present": false,
|
"disclosure_present": false,
|
||||||
"disclosure_wording": "",
|
"disclosure_wording": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user