This commit is contained in:
@@ -13,8 +13,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaVersion = "1.1"
|
||||
LegacySchemaVersion = "1.0"
|
||||
SchemaVersion = "1.2"
|
||||
PreviousSchemaVersion = "1.1"
|
||||
LegacySchemaVersion = "1.0"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -31,8 +32,14 @@ type Component struct {
|
||||
}
|
||||
|
||||
type Responsibility struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Assumed bool `json:"assumed,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type LegalContext struct {
|
||||
Categories []string `json:"categories,omitempty"`
|
||||
}
|
||||
|
||||
type Presentation struct {
|
||||
@@ -53,6 +60,7 @@ type Declaration struct {
|
||||
Language string `json:"language"`
|
||||
Components map[string]Component `json:"components"`
|
||||
EditorialResponsibility *Responsibility `json:"editorialResponsibility,omitempty"`
|
||||
LegalContext *LegalContext `json:"legalContext,omitempty"`
|
||||
Assurance string `json:"assurance"`
|
||||
Presentation *Presentation `json:"presentation,omitempty"`
|
||||
}
|
||||
@@ -90,6 +98,8 @@ var validActivities = map[string]bool{
|
||||
"transcription": true, "classification": true,
|
||||
}
|
||||
var validComponents = map[string]bool{"text": true, "coverImage": true, "image": true, "research": true, "translation": true, "audio": true, "video": true, "code": true, "other": true}
|
||||
var validLegalCategories = map[string]bool{"deepfake": true, "publicInterestText": true, "artisticCreativeSatiricalFictional": true, "otherVoluntary": true}
|
||||
var validResponsibilityRoles = map[string]bool{"publisher": true, "other": true}
|
||||
|
||||
func NewFromQuery(values url.Values, contextURL string) (Declaration, error) {
|
||||
return NewFromQueryWithOptions(values, contextURL, ParseOptions{DefaultLanguage: "de"})
|
||||
@@ -193,8 +203,13 @@ func NewFromQueryWithOptions(values url.Values, contextURL string, options Parse
|
||||
}
|
||||
responsibleName := clean(values.Get("responsible"), 200)
|
||||
responsibleURL := clean(values.Get("responsibleUrl"), 2048)
|
||||
if responsibleName != "" || responsibleURL != "" {
|
||||
d.EditorialResponsibility = &Responsibility{Name: responsibleName, URL: responsibleURL}
|
||||
responsibleRole := clean(values.Get("responsibleRole"), 24)
|
||||
if responsibleName != "" || responsibleURL != "" || responsibleRole != "" {
|
||||
d.EditorialResponsibility = &Responsibility{Assumed: responsibleName != "", Role: responsibleRole, Name: responsibleName, URL: responsibleURL}
|
||||
}
|
||||
legalCategories := splitCSV(values.Get("legalContext"))
|
||||
if len(legalCategories) > 0 {
|
||||
d.LegalContext = &LegalContext{Categories: legalCategories}
|
||||
}
|
||||
|
||||
presentation := &Presentation{
|
||||
@@ -222,9 +237,16 @@ func Validate(d Declaration) error {
|
||||
var problems []string
|
||||
switch d.SchemaVersion {
|
||||
case SchemaVersion:
|
||||
case PreviousSchemaVersion:
|
||||
if d.LegalContext != nil {
|
||||
problems = append(problems, "legalContext requires schemaVersion 1.2")
|
||||
}
|
||||
case LegacySchemaVersion:
|
||||
if d.Presentation != nil {
|
||||
problems = append(problems, "presentation requires schemaVersion 1.1")
|
||||
problems = append(problems, "presentation requires schemaVersion 1.1 or later")
|
||||
}
|
||||
if d.LegalContext != nil {
|
||||
problems = append(problems, "legalContext requires schemaVersion 1.2")
|
||||
}
|
||||
if d.Language != "de" && d.Language != "en" {
|
||||
problems = append(problems, "schemaVersion 1.0 supports only de and en")
|
||||
@@ -258,12 +280,47 @@ func Validate(d Declaration) error {
|
||||
if strings.TrimSpace(d.EditorialResponsibility.Name) == "" {
|
||||
problems = append(problems, "editorialResponsibility.name is required when editorialResponsibility is present")
|
||||
}
|
||||
if d.EditorialResponsibility.Role != "" && !validResponsibilityRoles[d.EditorialResponsibility.Role] {
|
||||
problems = append(problems, "editorialResponsibility.role must be publisher or other")
|
||||
}
|
||||
if d.EditorialResponsibility.URL != "" {
|
||||
if u, err := url.ParseRequestURI(d.EditorialResponsibility.URL); err != nil || u.Scheme == "" || u.Host == "" {
|
||||
problems = append(problems, "editorialResponsibility.url must be an absolute URL")
|
||||
}
|
||||
}
|
||||
}
|
||||
if d.LegalContext != nil {
|
||||
seenLegal := map[string]bool{}
|
||||
for _, category := range d.LegalContext.Categories {
|
||||
if !validLegalCategories[category] {
|
||||
problems = append(problems, "invalid legalContext category: "+category)
|
||||
}
|
||||
if seenLegal[category] {
|
||||
problems = append(problems, "duplicate legalContext category: "+category)
|
||||
}
|
||||
seenLegal[category] = true
|
||||
}
|
||||
if seenLegal["otherVoluntary"] && len(seenLegal) > 1 {
|
||||
problems = append(problems, "otherVoluntary cannot be combined with other legalContext categories")
|
||||
}
|
||||
if seenLegal["publicInterestText"] {
|
||||
text, ok := d.Components["text"]
|
||||
if !ok || text.AIExtent == "none" {
|
||||
problems = append(problems, "publicInterestText requires a text component with AI involvement")
|
||||
}
|
||||
}
|
||||
if seenLegal["deepfake"] {
|
||||
hasAIMedia := false
|
||||
for _, name := range []string{"coverImage", "image", "audio", "video"} {
|
||||
if c, ok := d.Components[name]; ok && c.AIExtent != "none" {
|
||||
hasAIMedia = true
|
||||
}
|
||||
}
|
||||
if !hasAIMedia {
|
||||
problems = append(problems, "deepfake requires an image, audio or video component with AI involvement")
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, c := range d.Components {
|
||||
if !validComponents[name] {
|
||||
problems = append(problems, "invalid component: "+name)
|
||||
|
||||
@@ -126,3 +126,39 @@ func TestEditorialResponsibilityRequiresName(t *testing.T) {
|
||||
t.Fatalf("expected editorial responsibility name validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalContextPublicInterestRequiresAIText(t *testing.T) {
|
||||
_, err := NewFromQuery(url.Values{
|
||||
"mode": {"article"}, "lang": {"de"}, "textExtent": {"none"},
|
||||
"legalContext": {"publicInterestText"},
|
||||
}, "https://example.org/context/v1")
|
||||
if err == nil || !strings.Contains(err.Error(), "publicInterestText requires a text component with AI involvement") {
|
||||
t.Fatalf("expected public-interest consistency error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalContextDeepfakeRequiresAIMedia(t *testing.T) {
|
||||
_, err := NewFromQuery(url.Values{
|
||||
"mode": {"article"}, "lang": {"de"}, "textExtent": {"full"},
|
||||
"legalContext": {"deepfake"},
|
||||
}, "https://example.org/context/v1")
|
||||
if err == nil || !strings.Contains(err.Error(), "deepfake requires an image, audio or video component with AI involvement") {
|
||||
t.Fatalf("expected deepfake consistency error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalContextAndResponsibilityAreSerialized(t *testing.T) {
|
||||
d, err := NewFromQuery(url.Values{
|
||||
"mode": {"article"}, "lang": {"de"}, "textExtent": {"partial"}, "textReview": {"expert"},
|
||||
"legalContext": {"publicInterestText"}, "responsibleRole": {"publisher"}, "responsible": {"Beispielredaktion"},
|
||||
}, "https://example.org/context/v1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.SchemaVersion != "1.2" || d.LegalContext == nil || len(d.LegalContext.Categories) != 1 {
|
||||
t.Fatalf("unexpected legal context: %#v", d)
|
||||
}
|
||||
if d.EditorialResponsibility == nil || !d.EditorialResponsibility.Assumed || d.EditorialResponsibility.Role != "publisher" {
|
||||
t.Fatalf("responsibility not explicitly assumed: %#v", d.EditorialResponsibility)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user