458 lines
16 KiB
Go
458 lines
16 KiB
Go
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
|
|
}
|