package declaration import ( "errors" "fmt" "net/url" "regexp" "sort" "strings" "time" "github.com/b1tsblog/ai-disclosure-standard/internal/i18n" ) const ( SchemaVersion = "1.2" LegacySchemaVersion = "1.1" LegacySchemaVersion10 = "1.0" ) var ( ErrCustomTextRequiresPro = errors.New("custom declaration text requires the licensed capability custom_text") ErrCustomBadgeRequiresPro = errors.New("custom badge presentation requires the licensed capability custom_badge") hexColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) ) type Component struct { AIExtent string `json:"aiExtent"` Activities []string `json:"activities,omitempty"` HumanReview string `json:"humanReview"` Note string `json:"note,omitempty"` } type Responsibility struct { Name string `json:"name"` URL string `json:"url,omitempty"` } // RegulatoryContext records factual context that can be relevant to Article 50 // of Regulation (EU) 2024/1689. It deliberately does not encode a legal // conclusion; assessment output is advisory and derived from these facts. type RegulatoryContext struct { Framework string `json:"framework,omitempty"` PublicInterestText bool `json:"publicInterestText,omitempty"` Deepfake bool `json:"deepfake,omitempty"` ArtisticCreativeSatiricalFictional bool `json:"artisticCreativeSatiricalFictional,omitempty"` SubstantialHumanReview bool `json:"substantialHumanReview,omitempty"` EditorialResponsibilityConfirmed bool `json:"editorialResponsibilityConfirmed,omitempty"` FirstExposureDisclosure bool `json:"firstExposureDisclosure,omitempty"` AccessibilityConsidered bool `json:"accessibilityConsidered,omitempty"` } // Article50Assessment is a non-binding technical decision-support result. // It must never be presented as legal advice or a legal determination. type Article50Finding struct { Code string `json:"code"` Severity string `json:"severity"` } type Article50Assessment struct { Code string `json:"code"` Severity string `json:"severity"` Applicable bool `json:"potentiallyApplicable"` Findings []Article50Finding `json:"findings,omitempty"` Warnings []string `json:"warnings,omitempty"` } type Presentation struct { Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` BadgeLabel string `json:"badgeLabel,omitempty"` BadgeMessage string `json:"badgeMessage,omitempty"` LeftColor string `json:"leftColor,omitempty"` RightColor string `json:"rightColor,omitempty"` } type Declaration struct { Context string `json:"@context"` Type string `json:"@type"` SchemaVersion string `json:"schemaVersion"` Subject string `json:"subject,omitempty"` DeclaredAt string `json:"declaredAt,omitempty"` Language string `json:"language"` Components map[string]Component `json:"components"` EditorialResponsibility *Responsibility `json:"editorialResponsibility,omitempty"` RegulatoryContext *RegulatoryContext `json:"regulatoryContext,omitempty"` Assurance string `json:"assurance"` Presentation *Presentation `json:"presentation,omitempty"` } type Preset struct { ID string Extent string Activities []string Review string } type ParseOptions struct { AllowCustomText bool AllowCustomBadge bool DefaultLanguage string } var Presets = map[string]Preset{ "no-ai": {ID: "no-ai", Extent: "none", Review: "none"}, "research": {ID: "research", Extent: "assisted", Activities: []string{"research"}, Review: "editorial"}, "summary": {ID: "summary", Extent: "assisted", Activities: []string{"summarisation"}, Review: "editorial"}, "full": {ID: "full", Extent: "full", Activities: []string{"generation"}, Review: "editorial"}, } var validExtents = map[string]bool{"none": true, "assisted": true, "partial": true, "mostly": true, "full": true} var validReviews = map[string]bool{"none": true, "basic": true, "editorial": true, "expert": true} var validAssurance = map[string]bool{"selfDeclared": true, "technicallyRecorded": true, "signed": true, "verified": true} var validActivities = map[string]bool{ "research": true, "summarisation": true, "drafting": true, "generation": true, "translation": true, "editing": true, "imageGeneration": true, "codeGeneration": true, "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} func NewFromQuery(values url.Values, contextURL string) (Declaration, error) { return NewFromQueryWithOptions(values, contextURL, ParseOptions{DefaultLanguage: "de"}) } func NewFromQueryWithOptions(values url.Values, contextURL string, options ParseOptions) (Declaration, error) { lang := i18n.Normalize(clean(values.Get("lang"), 16)) if lang == "" { lang = i18n.Normalize(options.DefaultLanguage) } if lang == "" { lang = "en" } if !i18n.Supported(lang) { return Declaration{}, fmt.Errorf("unsupported language %q", lang) } componentName := clean(values.Get("component"), 32) if componentName == "" { componentName = "text" } if !validComponents[componentName] { return Declaration{}, fmt.Errorf("unknown component %q", componentName) } extent := clean(values.Get("extent"), 24) activities := splitCSV(values.Get("activities")) review := clean(values.Get("review"), 24) if presetID := clean(values.Get("preset"), 24); presetID != "" { preset, ok := Presets[presetID] if !ok { return Declaration{}, fmt.Errorf("unknown preset %q", presetID) } if extent == "" { extent = preset.Extent } if len(activities) == 0 { activities = append([]string(nil), preset.Activities...) } if review == "" { review = preset.Review } } if extent == "" { extent = "assisted" } if review == "" { review = "editorial" } assurance := clean(values.Get("assurance"), 32) if assurance == "" { assurance = "selfDeclared" } components := map[string]Component{} if clean(values.Get("mode"), 16) == "article" { for _, name := range []string{"text", "coverImage", "image", "research", "translation", "audio", "video", "code"} { componentExtent := clean(values.Get(name+"Extent"), 24) if componentExtent == "" { continue } componentReview := clean(values.Get(name+"Review"), 24) if componentReview == "" { if componentExtent == "none" { componentReview = "none" } else { componentReview = "editorial" } } componentActivities := splitCSV(values.Get(name + "Activities")) if len(componentActivities) == 0 { switch name { case "research": if componentExtent != "none" { componentActivities = []string{"research"} } case "translation": if componentExtent != "none" { componentActivities = []string{"translation"} } case "coverImage", "image": if componentExtent != "none" { componentActivities = []string{"imageGeneration"} } case "code": if componentExtent != "none" { componentActivities = []string{"codeGeneration"} } } } components[name] = Component{AIExtent: componentExtent, Activities: componentActivities, HumanReview: componentReview, Note: clean(values.Get(name+"Note"), 500)} } } if len(components) == 0 { components[componentName] = Component{AIExtent: extent, Activities: activities, HumanReview: review, Note: clean(values.Get("note"), 500)} } d := Declaration{ Context: contextURL, Type: "AIUsageDeclaration", SchemaVersion: SchemaVersion, Subject: clean(values.Get("subject"), 2048), DeclaredAt: clean(values.Get("declaredAt"), 64), Language: lang, Components: components, Assurance: assurance, } responsibleName := clean(values.Get("responsible"), 200) responsibleURL := clean(values.Get("responsibleUrl"), 2048) if responsibleName != "" || responsibleURL != "" { d.EditorialResponsibility = &Responsibility{Name: responsibleName, URL: responsibleURL} } regulatory := &RegulatoryContext{ Framework: "EU-AI-Act-Article-50", PublicInterestText: parseBool(values.Get("publicInterestText")), Deepfake: parseBool(values.Get("deepfake")), ArtisticCreativeSatiricalFictional: parseBool(values.Get("artisticCreativeSatiricalFictional")), SubstantialHumanReview: parseBool(values.Get("substantialHumanReview")), EditorialResponsibilityConfirmed: parseBool(values.Get("editorialResponsibilityConfirmed")), FirstExposureDisclosure: parseBool(values.Get("firstExposureDisclosure")), AccessibilityConsidered: parseBool(values.Get("accessibilityConsidered")), } if regulatory.PublicInterestText || regulatory.Deepfake || regulatory.ArtisticCreativeSatiricalFictional || regulatory.SubstantialHumanReview || regulatory.EditorialResponsibilityConfirmed || regulatory.FirstExposureDisclosure || regulatory.AccessibilityConsidered { d.RegulatoryContext = regulatory } presentation := &Presentation{ Title: clean(values.Get("customTitle"), 120), Description: clean(values.Get("customDescription"), 500), BadgeLabel: clean(values.Get("badgeLabel"), 40), BadgeMessage: clean(values.Get("badgeMessage"), 80), LeftColor: clean(values.Get("leftColor"), 7), RightColor: clean(values.Get("rightColor"), 7), } if presentation.Title != "" || presentation.Description != "" { if !options.AllowCustomText { return Declaration{}, ErrCustomTextRequiresPro } } if presentation.BadgeLabel != "" || presentation.BadgeMessage != "" || presentation.LeftColor != "" || presentation.RightColor != "" { if !options.AllowCustomBadge { return Declaration{}, ErrCustomBadgeRequiresPro } } if !presentation.empty() { d.Presentation = presentation } return d, Validate(d) } func Validate(d Declaration) error { var problems []string switch d.SchemaVersion { case SchemaVersion: case LegacySchemaVersion: if d.RegulatoryContext != nil { problems = append(problems, "regulatoryContext requires schemaVersion 1.2") } case LegacySchemaVersion10: if d.Presentation != nil { problems = append(problems, "presentation requires schemaVersion 1.1 or newer") } if d.RegulatoryContext != nil { problems = append(problems, "regulatoryContext requires schemaVersion 1.2") } if d.Language != "de" && d.Language != "en" { problems = append(problems, "schemaVersion 1.0 supports only de and en") } default: problems = append(problems, "unsupported schemaVersion") } if d.Type != "AIUsageDeclaration" { problems = append(problems, "@type must be AIUsageDeclaration") } if !i18n.Supported(d.Language) { problems = append(problems, "unsupported language") } if !validAssurance[d.Assurance] { problems = append(problems, "invalid assurance") } if len(d.Components) == 0 { problems = append(problems, "at least one component is required") } if d.Subject != "" { if u, err := url.ParseRequestURI(d.Subject); err != nil || u.Scheme == "" || u.Host == "" { problems = append(problems, "subject must be an absolute URL") } } if d.DeclaredAt != "" { if _, err := time.Parse(time.RFC3339, d.DeclaredAt); err != nil { problems = append(problems, "declaredAt must be RFC3339") } } if d.EditorialResponsibility != nil && 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 rc := d.RegulatoryContext; rc != nil { if rc.Framework != "" && rc.Framework != "EU-AI-Act-Article-50" { problems = append(problems, "regulatoryContext.framework must be EU-AI-Act-Article-50") } } for name, c := range d.Components { if !validComponents[name] { problems = append(problems, "invalid component: "+name) } if !validExtents[c.AIExtent] { problems = append(problems, "invalid aiExtent for "+name) } if !validReviews[c.HumanReview] { problems = append(problems, "invalid humanReview for "+name) } seen := map[string]bool{} for _, activity := range c.Activities { if !validActivities[activity] { problems = append(problems, "invalid activity for "+name+": "+activity) } if seen[activity] { problems = append(problems, "duplicate activity for "+name+": "+activity) } seen[activity] = true } if c.AIExtent == "none" && len(c.Activities) > 0 { problems = append(problems, "activities must be empty when aiExtent is none") } } if p := d.Presentation; p != nil { if runeLen(p.Title) > 120 { problems = append(problems, "presentation.title is too long") } if runeLen(p.Description) > 500 { problems = append(problems, "presentation.description is too long") } if runeLen(p.BadgeLabel) > 40 { problems = append(problems, "presentation.badgeLabel is too long") } if runeLen(p.BadgeMessage) > 80 { problems = append(problems, "presentation.badgeMessage is too long") } if p.LeftColor != "" && !hexColorPattern.MatchString(p.LeftColor) { problems = append(problems, "presentation.leftColor must be a six-digit hex colour") } if p.RightColor != "" && !hexColorPattern.MatchString(p.RightColor) { problems = append(problems, "presentation.rightColor must be a six-digit hex colour") } } if len(problems) > 0 { sort.Strings(problems) return fmt.Errorf("%s", strings.Join(problems, "; ")) } return nil } func IsValidExtent(value string) bool { return validExtents[value] } func (p *Presentation) empty() bool { return p == nil || (p.Title == "" && p.Description == "" && p.BadgeLabel == "" && p.BadgeMessage == "" && p.LeftColor == "" && p.RightColor == "") } // AssessArticle50 derives a conservative, non-binding decision-support result // from the factual context recorded in a declaration. func AssessArticle50(d Declaration) Article50Assessment { rc := d.RegulatoryContext if rc == nil { return Article50Assessment{Code: "not_assessed", Severity: "neutral", Applicable: false} } result := Article50Assessment{Code: "voluntary_transparency", Severity: "neutral", Applicable: false} if rc.Deepfake { result.Findings = append(result.Findings, Article50Finding{Code: "deepfake_disclosure_relevant", Severity: "attention"}) } if rc.PublicInterestText { if rc.SubstantialHumanReview && rc.EditorialResponsibilityConfirmed { result.Findings = append(result.Findings, Article50Finding{Code: "public_interest_text_possible_exemption", Severity: "caution"}) } else { result.Findings = append(result.Findings, Article50Finding{Code: "public_interest_text_disclosure_relevant", Severity: "attention"}) } } if len(result.Findings) == 1 { result.Code = result.Findings[0].Code result.Severity = result.Findings[0].Severity result.Applicable = true } else if len(result.Findings) > 1 { result.Code = "multiple_article50_contexts" result.Severity = "caution" result.Applicable = true for _, finding := range result.Findings { if finding.Severity == "attention" { result.Severity = "attention" break } } } if result.Applicable && !rc.FirstExposureDisclosure { result.Warnings = append(result.Warnings, "first_exposure_not_confirmed") } if result.Applicable && !rc.AccessibilityConsidered { result.Warnings = append(result.Warnings, "accessibility_not_confirmed") } if rc.PublicInterestText && rc.SubstantialHumanReview && !rc.EditorialResponsibilityConfirmed { result.Warnings = append(result.Warnings, "editorial_responsibility_not_confirmed") } if rc.Deepfake && rc.ArtisticCreativeSatiricalFictional { result.Warnings = append(result.Warnings, "artistic_context_disclosure_manner") } return result } func parseBool(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "1", "true", "yes", "on": return true default: return false } } func clean(s string, max int) string { r := []rune(strings.TrimSpace(s)) if len(r) > max { r = r[:max] } return string(r) } func runeLen(s string) int { return len([]rune(s)) } func splitCSV(s string) []string { if strings.TrimSpace(s) == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) seen := map[string]bool{} for _, p := range parts { p = clean(p, 32) if p != "" && !seen[p] { out = append(out, p) seen[p] = true } } sort.Strings(out) return out }