This commit is contained in:
2026-07-20 21:03:05 +02:00
parent dc60551e07
commit c3c85eef21
70 changed files with 8049 additions and 1 deletions
+343
View File
@@ -0,0 +1,343 @@
package declaration
import (
"errors"
"fmt"
"net/url"
"regexp"
"sort"
"strings"
"time"
"github.com/b1tsblog/ai-disclosure-standard/internal/i18n"
)
const (
SchemaVersion = "1.1"
LegacySchemaVersion = "1.0"
)
var (
ErrCustomTextRequiresPro = errors.New("custom declaration text requires the Pro feature custom_text")
ErrCustomBadgeRequiresPro = errors.New("custom badge presentation requires the Pro feature 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"`
}
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"`
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: "editorial"},
"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}
}
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.Presentation != nil {
problems = append(problems, "presentation requires schemaVersion 1.1")
}
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")
}
}
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 == "")
}
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
}
+114
View File
@@ -0,0 +1,114 @@
package declaration
import (
"errors"
"net/url"
"testing"
)
func TestPresetQuery(t *testing.T) {
v := url.Values{"preset": {"research"}, "subject": {"https://example.org/post"}, "lang": {"fr"}}
d, err := NewFromQuery(v, "https://example.org/context/v1")
if err != nil {
t.Fatal(err)
}
c := d.Components["text"]
if c.AIExtent != "assisted" || len(c.Activities) != 1 || c.Activities[0] != "research" {
t.Fatalf("unexpected component: %#v", c)
}
if d.Language != "fr" {
t.Fatalf("unexpected language: %s", d.Language)
}
}
func TestNoAIRejectsActivities(t *testing.T) {
d := Declaration{Context: "https://example.org/context/v1", Type: "AIUsageDeclaration", SchemaVersion: SchemaVersion, Language: "de", Assurance: "selfDeclared", Components: map[string]Component{"text": {AIExtent: "none", Activities: []string{"research"}, HumanReview: "editorial"}}}
if Validate(d) == nil {
t.Fatal("expected validation error")
}
}
func TestCustomRequiresPro(t *testing.T) {
v := url.Values{"preset": {"research"}, "lang": {"en"}, "customTitle": {"Custom"}}
_, err := NewFromQuery(v, "https://example.org/context/v1")
if !errors.Is(err, ErrCustomTextRequiresPro) {
t.Fatalf("unexpected error: %v", err)
}
d, err := NewFromQueryWithOptions(v, "https://example.org/context/v1", ParseOptions{AllowCustomText: true, DefaultLanguage: "en"})
if err != nil {
t.Fatal(err)
}
if d.Presentation == nil || d.Presentation.Title != "Custom" {
t.Fatalf("missing presentation: %#v", d.Presentation)
}
}
func TestLegacySchemaValidation(t *testing.T) {
d := Declaration{Context: "https://example.org/context/v1", Type: "AIUsageDeclaration", SchemaVersion: LegacySchemaVersion, Language: "en", Assurance: "selfDeclared", Components: map[string]Component{"text": {AIExtent: "assisted", HumanReview: "editorial"}}}
if err := Validate(d); err != nil {
t.Fatalf("legacy declaration should remain valid: %v", err)
}
d.Presentation = &Presentation{Title: "Not allowed in 1.0"}
if Validate(d) == nil {
t.Fatal("expected presentation to require schema 1.1")
}
}
func TestArticleDeclarationFromQuery(t *testing.T) {
q := url.Values{
"mode": {"article"}, "lang": {"de"},
"textExtent": {"none"}, "imageExtent": {"full"}, "researchExtent": {"assisted"},
}
d, err := NewFromQuery(q, "https://example.org/context/v1")
if err != nil {
t.Fatal(err)
}
if len(d.Components) != 3 {
t.Fatalf("components=%d", len(d.Components))
}
if d.Components["text"].AIExtent != "none" {
t.Fatalf("unexpected text extent")
}
if d.Components["text"].HumanReview != "none" {
t.Fatalf("no-AI component review=%q, want none", d.Components["text"].HumanReview)
}
if d.Components["research"].HumanReview != "editorial" {
t.Fatalf("AI-assisted component review=%q, want editorial default", d.Components["research"].HumanReview)
}
if got := d.Components["research"].Activities; len(got) != 1 || got[0] != "research" {
t.Fatalf("research activities=%v", got)
}
if got := d.Components["image"].Activities; len(got) != 1 || got[0] != "imageGeneration" {
t.Fatalf("image activities=%v", got)
}
}
func TestArticleDeclarationKeepsPerComponentReviews(t *testing.T) {
q := url.Values{
"mode": {"article"}, "lang": {"de"},
"textExtent": {"assisted"}, "textReview": {"basic"},
"imageExtent": {"full"}, "imageReview": {"expert"},
}
d, err := NewFromQuery(q, "https://example.org/context/v1")
if err != nil {
t.Fatal(err)
}
if got := d.Components["text"].HumanReview; got != "basic" {
t.Fatalf("text review=%q", got)
}
if got := d.Components["image"].HumanReview; got != "expert" {
t.Fatalf("image review=%q", got)
}
}
func TestAssuranceFromQuery(t *testing.T) {
for _, assurance := range []string{"selfDeclared", "technicallyRecorded", "signed", "verified"} {
d, err := NewFromQuery(url.Values{"preset": {"research"}, "lang": {"de"}, "assurance": {assurance}}, "https://example.org/context/v1")
if err != nil {
t.Fatalf("assurance %s: %v", assurance, err)
}
if d.Assurance != assurance {
t.Fatalf("assurance=%q, want %q", d.Assurance, assurance)
}
}
}