+2
-6
@@ -1,13 +1,9 @@
|
||||
BASE_URL=http://localhost:8080
|
||||
OUTPUT_BASE_URL=http://localhost:8080
|
||||
PUBLIC_NAME=AI Usage Disclosure
|
||||
CONTACT_URL=https://ai.trustednet.eu
|
||||
CONTACT_URL=https://b1tsblog.org/page/ai
|
||||
DEFAULT_LANGUAGE=de
|
||||
<<<<<<< HEAD
|
||||
TRUST_PROXY=false
|
||||
=======
|
||||
BULK_URL=http://localhost:8088
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
|
||||
# Runtime mode: full (UI + API), api (API without public product/generator pages), bulk.
|
||||
SERVICE_MODE=full
|
||||
@@ -17,7 +13,7 @@ API_ALLOWED_ORIGIN=*
|
||||
# No private keys or configurable public keys belong in this application.
|
||||
LICENSE_TOKEN=
|
||||
LICENSE_MODE=offline
|
||||
LICENSE_SERVER_URL=https://license.trustednet.eu
|
||||
LICENSE_SERVER_URL=
|
||||
LICENSE_INSTANCE_ID=
|
||||
LICENSE_CACHE_FILE=/data/license-lease.json
|
||||
LICENSE_REFRESH_INTERVAL=15m
|
||||
|
||||
@@ -1,28 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
<<<<<<< HEAD
|
||||
## 2.0.0
|
||||
=======
|
||||
## 1.8.2 - 2026-07-23
|
||||
|
||||
- Fixed the Bulk results view so it previews the same accessible badge-plus-visible-text disclosure used by the recommended `embedHtml` export.
|
||||
- Renamed the raw badge link to `SVG (nur Grafik)` to make clear that the standalone SVG is only the visual asset and not the recommended first-exposure integration.
|
||||
- Kept Bulk HTML exports unchanged on `embedHtml`; this release fixes the misleading preview/link presentation rather than introducing a second rendering path.
|
||||
|
||||
## 1.8.1 - 2026-07-23
|
||||
|
||||
- Added an accessible first-exposure embed with visible link text next to the SVG badge instead of relying on SVG/`alt` information alone.
|
||||
- Added `accessibleText` and `embedHtml` to `GET /v1/render`; `embedHtml` combines the visible disclosure and inline JSON-LD in one copy/paste artifact.
|
||||
- The normal generator now renders HTML/Markdown/JSON-LD through the core render endpoint and copies the complete HTML + JSON-LD embed by default.
|
||||
- Bulk HTML exports now use `embedHtml`, so each subject receives visible disclosure markup and JSON-LD in one step.
|
||||
- Flat SVG badges automatically choose black or white text per segment for at least 4.5:1 contrast; the old overlay gradient was removed so the computed contrast remains deterministic.
|
||||
- Emoji/icon badges enforce at least 3:1 foreground/background contrast by falling back to black or white when a custom colour pair is insufficient; the generator shows a corresponding warning.
|
||||
- Increased the recommended linked disclosure target to at least 36 CSS pixels high and added a visible text label in the generator preview.
|
||||
- Adopted the supplied registry deployment: `compose.yaml` now uses `git.send.nrw/sendnrw/ai-disclosure-standard:latest` and `:bulk-latest`, and `.gitea/workflows/registry-bulk.yml` builds the separate bulk tags.
|
||||
- Removed the superseded GHCR bulk workflow to avoid competing release pipelines.
|
||||
|
||||
## 1.8.0 - 2026-07-22
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
|
||||
### Article 50 decision support
|
||||
|
||||
|
||||
@@ -23,11 +23,7 @@ build:
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/ai-disclosure ./cmd/server
|
||||
|
||||
docker-build:
|
||||
<<<<<<< HEAD
|
||||
docker build --build-arg VERSION=$(VERSION) -t ai-disclosure-standard:$(VERSION)-local .
|
||||
=======
|
||||
docker build -t ai-disclosure-standard:1.8.2-local .
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
|
||||
docker-build-bulk:
|
||||
docker build -f Dockerfile.bulk --build-arg VERSION=$(VERSION) -t ai-disclosure-standard-bulk:$(VERSION)-local .
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
<<<<<<< HEAD
|
||||
# AI Disclosure Standard 2.0.0
|
||||
=======
|
||||
# AI Disclosure Standard 1.8.2
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
|
||||
Ein mehrsprachiger, selbst hostbarer Go-Dienst für sichtbare und maschinenlesbare Erklärungen zur KI-Nutzung in Artikeln, Webseiten und einzelnen Inhaltsbestandteilen.
|
||||
|
||||
Die Community-Basis bleibt offen und ohne Plattformzwang nutzbar. Für professionelle Betreiber stehen zusätzlich lizenzierbare Laufzeitfunktionen wie eigene Darstellung, Bulk-Verarbeitung, Export-Bundles und White-Label-Betrieb zur Verfügung. Lizenzen werden ausschließlich durch die separat betriebene **Universal License Platform** ausgestellt.
|
||||
|
||||
<<<<<<< HEAD
|
||||
## Funktionsumfang
|
||||
|
||||
### Offener Kern
|
||||
|
||||
- Einzeldeklarationen und zusammengefasste Artikel-/Webseiten-Deklarationen;
|
||||
=======
|
||||
- einzelne Deklarationen und zusammengefasste Artikel-/Webseiten-Deklarationen;
|
||||
- SVG-Badges, sichtbarer barrierearmer HTML-Hinweis und JSON-LD unter einem gemeinsamen Link;
|
||||
- empfohlener Ein-Schritt-Embed: sichtbarer Hinweis + dekoratives SVG + inline JSON-LD in einem HTML-Snippet;
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
- Text, Titelbild, weitere Bilder, Recherche, Übersetzung, Audio, Video und Code getrennt erfassbar;
|
||||
- SVG-Badges, HTML-Erklärung und JSON-LD aus derselben Deklaration;
|
||||
- automatisch erzeugte Fließtext-Zusammenfassung und sauber gerasterte Tabellenansicht;
|
||||
@@ -86,51 +76,22 @@ go run .\cmd\server
|
||||
Danach:
|
||||
|
||||
```text
|
||||
<<<<<<< HEAD
|
||||
Generator: http://localhost:8080/
|
||||
Produktseite: http://localhost:8080/product
|
||||
Hintergrund: http://localhost:8080/background
|
||||
Capabilities: http://localhost:8080/v1/capabilities
|
||||
Health: http://localhost:8080/healthz
|
||||
Readiness: http://localhost:8080/readyz
|
||||
=======
|
||||
Generator: http://localhost:8080/
|
||||
Produktseite: http://localhost:8080/product
|
||||
Hintergrund: http://localhost:8080/background
|
||||
Impressum: http://localhost:8080/impressum
|
||||
Datenschutz: http://localhost:8080/datenschutz
|
||||
Barrierefrei: http://localhost:8080/barrierefreiheit
|
||||
Healthcheck: http://localhost:8080/healthz
|
||||
Funktionen: http://localhost:8080/v1/capabilities
|
||||
Bulk (Compose-Port): http://localhost:8088/
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
```
|
||||
|
||||
Go lädt `.env` beim direkten Start nicht automatisch. Docker Compose kann sie explizit verwenden:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
docker compose --env-file .env pull
|
||||
docker compose --env-file .env up -d
|
||||
docker compose --env-file .env up -d --build
|
||||
```
|
||||
|
||||
<<<<<<< HEAD
|
||||
## Betriebsmodi
|
||||
=======
|
||||
Die bereitgestellte `compose.yaml` verwendet die Registry-Images `git.send.nrw/sendnrw/ai-disclosure-standard:latest` und `:bulk-latest`, bindet beide Dienste an das externe Netzwerk `proxy` und veröffentlicht den Bulk-Dienst zusätzlich auf Host-Port `8088`. Das Netzwerk `proxy` muss vor dem Start bereits existieren.
|
||||
|
||||
|
||||
## Gitea Actions: Bulk-Image
|
||||
|
||||
Der Workflow `.gitea/workflows/registry-bulk.yml` baut `Dockerfile.bulk` unabhängig vom Hauptimage und veröffentlicht in der konfigurierten Registry unter anderem:
|
||||
|
||||
```text
|
||||
git.send.nrw/sendnrw/<repository>:bulk-latest
|
||||
git.send.nrw/sendnrw/<repository>:bulk-<version>
|
||||
```
|
||||
|
||||
Er verwendet dieselbe Registry- und Secret-Konfiguration wie der vorhandene Hauptimage-Workflow (`DOCKER_USERNAME` / `DOCKER_PASSWORD`).
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
|
||||
`SERVICE_MODE` trennt öffentliche Oberfläche und Server-to-Server-Betrieb:
|
||||
|
||||
@@ -142,42 +103,20 @@ Er verwendet dieselbe Registry- und Secret-Konfiguration wie der vorhandene Haup
|
||||
|
||||
Der dedizierte Bulk-Container setzt bereits:
|
||||
|
||||
<<<<<<< HEAD
|
||||
```text
|
||||
SERVICE_MODE=bulk
|
||||
REQUIRE_LICENSE=true
|
||||
BULK_REQUIRE_API_KEY=true
|
||||
API_ALLOWED_ORIGIN=
|
||||
```
|
||||
=======
|
||||
1. Kennzeichnung im normalen Generator konfigurieren.
|
||||
2. **Als Bulk-Vorlage öffnen** auswählen.
|
||||
3. Optional ein Website-Profil mit wiederkehrenden Angaben wie Autor, Impressums-/Verantwortlichkeits-URL und Beschwerdestelle im Browser speichern.
|
||||
4. Absolute URLs oder relative Pfade einfügen. Für relative Pfade kann einmalig eine Basis-URL gesetzt werden.
|
||||
5. Das komplette HTML (sichtbarer Hinweis + JSON-LD), Markdown, separates JSON-LD, JSONL oder CSV erzeugen und kopieren bzw. herunterladen.
|
||||
|
||||
Der HTML-Export verwendet standardmäßig `embedHtml` aus dem Core: Das SVG ist dekorativ (`alt=""`), der Link enthält unmittelbar sichtbaren Text und hat ein größeres Klickziel; direkt danach folgt das JSON-LD als `<script type="application/ld+json">`.
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
|
||||
## Professioneller Bulk-Betrieb
|
||||
|
||||
Build:
|
||||
|
||||
<<<<<<< HEAD
|
||||
```bash
|
||||
docker build -f Dockerfile.bulk -t ai-disclosure-standard-bulk:2.0.0 .
|
||||
```
|
||||
=======
|
||||
| Variable | Standard | Bedeutung |
|
||||
|---|---|---|
|
||||
| `BULK_URL` | leer / in `.env.example` `http://localhost:8088` | öffentliche URL des Bulk-Generators; aktiviert den Übergabe-Button im normalen Generator |
|
||||
| `CORE_INTERNAL_URL` | `http://app:8080` | feste interne Origin des Disclosure-Core; der Bulk-Dienst folgt keinen frei eingegebenen Ziel-URLs |
|
||||
| `DISCLOSURE_BASE_URL` | `http://localhost:8080` | öffentliche Core-Origin für Links in der Bulk-Oberfläche |
|
||||
| `GENERATOR_URL` | wie `DISCLOSURE_BASE_URL` | öffentlicher Link zurück zum normalen Generator |
|
||||
| `BULK_MAX_URLS` | `500` | maximale Zahl an Inhalts-URLs je Lauf, maximal 5000 |
|
||||
| `BULK_WORKERS` | `4` | parallele Core-Render-Aufrufe, maximal 32 |
|
||||
| `BULK_REQUEST_TIMEOUT` | `8s` | Timeout je Core-Aufruf |
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
|
||||
Der Bulk-Endpunkt lautet:
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,552 +0,0 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const PROFILE_KEY = 'ai-disclosure.bulk.profiles.v1';
|
||||
const TEMPLATE_KEY = 'ai-disclosure.bulk.templates.v1';
|
||||
const EXPORT_FORMAT = 'ai-disclosure-bulk-browser-data';
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
const profileFields = [
|
||||
['profile-lang', 'lang'],
|
||||
['profile-role', 'legalRole'],
|
||||
['profile-use-context', 'useContext'],
|
||||
['profile-assurance', 'assurance'],
|
||||
['profile-author', 'author'],
|
||||
['profile-author-url', 'authorUrl'],
|
||||
['profile-responsible-role', 'responsibleRole'],
|
||||
['profile-responsible', 'responsible'],
|
||||
['profile-responsible-url', 'responsibleUrl'],
|
||||
['profile-complaint-name', 'complaintName'],
|
||||
['profile-complaint-email', 'complaintEmail'],
|
||||
['profile-complaint-url', 'complaintUrl']
|
||||
];
|
||||
|
||||
let cfg = null;
|
||||
let activeTemplate = null;
|
||||
let currentResults = [];
|
||||
let exportsCache = {};
|
||||
|
||||
function readStore(key) {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(key, value) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function setStatus(text, level = '') {
|
||||
const target = $('template-status');
|
||||
target.textContent = text;
|
||||
target.className = `status ${level}`.trim();
|
||||
}
|
||||
|
||||
function addMessage(text, level = '') {
|
||||
const p = document.createElement('p');
|
||||
p.className = `message ${level}`.trim();
|
||||
p.textContent = text;
|
||||
$('url-messages').appendChild(p);
|
||||
}
|
||||
|
||||
function safeHTTPURL(raw) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null;
|
||||
return url;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTemplate(raw) {
|
||||
const value = raw.trim();
|
||||
if (!value) throw new Error('Bitte zuerst eine Declaration-URL oder einen Query-String einfügen.');
|
||||
let params;
|
||||
const absolute = safeHTTPURL(value);
|
||||
if (absolute) {
|
||||
params = new URLSearchParams(absolute.search);
|
||||
} else {
|
||||
const query = value.startsWith('?') ? value.slice(1) : value;
|
||||
params = new URLSearchParams(query);
|
||||
}
|
||||
params.delete('subject');
|
||||
params.delete('link');
|
||||
if ([...params.keys()].length === 0) throw new Error('Die Vorlage enthält keine Parameter.');
|
||||
return params;
|
||||
}
|
||||
|
||||
function activateTemplate(params, label = 'Vorlage geladen') {
|
||||
activeTemplate = new URLSearchParams(params);
|
||||
activeTemplate.delete('subject');
|
||||
activeTemplate.delete('link');
|
||||
$('template-input').value = activeTemplate.toString();
|
||||
renderTemplateParams();
|
||||
setStatus(`${label}: ${[...activeTemplate.keys()].length} Parameter aktiv.`, 'good');
|
||||
}
|
||||
|
||||
function renderTemplateParams() {
|
||||
const list = $('template-params');
|
||||
list.replaceChildren();
|
||||
if (!activeTemplate) return;
|
||||
const entries = [...activeTemplate.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
for (const [key, value] of entries) {
|
||||
const row = document.createElement('div');
|
||||
const dt = document.createElement('dt');
|
||||
const dd = document.createElement('dd');
|
||||
dt.textContent = key;
|
||||
dd.textContent = value;
|
||||
row.append(dt, dd);
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshTemplateSelect(selected = '') {
|
||||
const templates = readStore(TEMPLATE_KEY);
|
||||
const select = $('saved-template-select');
|
||||
select.replaceChildren(new Option('Gespeicherte Vorlage wählen …', ''));
|
||||
Object.keys(templates).sort((a, b) => a.localeCompare(b)).forEach(name => select.add(new Option(name, name)));
|
||||
if (selected && templates[selected]) select.value = selected;
|
||||
}
|
||||
|
||||
function saveTemplate() {
|
||||
if (!activeTemplate) {
|
||||
setStatus('Bitte zuerst eine gültige Vorlage laden.', 'error');
|
||||
return;
|
||||
}
|
||||
const name = $('template-name').value.trim();
|
||||
if (!name) {
|
||||
setStatus('Bitte einen Namen für die Vorlage angeben.', 'error');
|
||||
$('template-name').focus();
|
||||
return;
|
||||
}
|
||||
const templates = readStore(TEMPLATE_KEY);
|
||||
templates[name] = {query: activeTemplate.toString(), updatedAt: new Date().toISOString()};
|
||||
writeStore(TEMPLATE_KEY, templates);
|
||||
refreshTemplateSelect(name);
|
||||
setStatus(`Vorlage „${name}“ wurde nur in diesem Browser gespeichert.`, 'good');
|
||||
}
|
||||
|
||||
function loadSavedTemplate(name) {
|
||||
if (!name) return;
|
||||
const item = readStore(TEMPLATE_KEY)[name];
|
||||
if (!item || typeof item.query !== 'string') return;
|
||||
$('template-name').value = name;
|
||||
activateTemplate(new URLSearchParams(item.query), `Vorlage „${name}“ geladen`);
|
||||
}
|
||||
|
||||
function deleteTemplate() {
|
||||
const name = $('saved-template-select').value;
|
||||
if (!name) return;
|
||||
const templates = readStore(TEMPLATE_KEY);
|
||||
delete templates[name];
|
||||
writeStore(TEMPLATE_KEY, templates);
|
||||
refreshTemplateSelect();
|
||||
if ($('template-name').value === name) $('template-name').value = '';
|
||||
setStatus(`Vorlage „${name}“ wurde aus diesem Browser gelöscht.`);
|
||||
}
|
||||
|
||||
function emptyProfileForm() {
|
||||
$('profile-name').value = '';
|
||||
for (const [id] of profileFields) $(id).value = '';
|
||||
$('profile-select').value = '';
|
||||
}
|
||||
|
||||
function profileFromForm() {
|
||||
const profile = {};
|
||||
for (const [id, key] of profileFields) {
|
||||
const value = $(id).value.trim();
|
||||
if (value) profile[key] = value;
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
function validateProfile(profile) {
|
||||
const urlKeys = ['authorUrl', 'responsibleUrl', 'complaintUrl'];
|
||||
for (const key of urlKeys) {
|
||||
if (profile[key] && !safeHTTPURL(profile[key])) return `${key} muss eine absolute http(s)-URL ohne Zugangsdaten sein.`;
|
||||
}
|
||||
if (profile.complaintEmail && !$('profile-complaint-email').checkValidity()) return 'Die E-Mail-Adresse der Rückmeldestelle ist nicht gültig.';
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateMergedParams(params) {
|
||||
if (params.get('authorUrl') && !params.get('author')) return 'Zu einer Autoren-URL muss auch ein Autor bzw. eine Autorin angegeben werden.';
|
||||
const responsibilityStarted = params.get('responsibleRole') || params.get('responsible') || params.get('responsibleUrl');
|
||||
if (responsibilityStarted && (!params.get('responsibleRole') || !params.get('responsible'))) return 'Bei redaktioneller Verantwortung sind Rolle und Name/Organisation gemeinsam erforderlich.';
|
||||
const complaintStarted = params.get('complaintName') || params.get('complaintEmail') || params.get('complaintUrl');
|
||||
if (complaintStarted && (!params.get('complaintName') || (!params.get('complaintEmail') && !params.get('complaintUrl')))) return 'Die Rückmeldestelle benötigt einen Namen und mindestens E-Mail oder Kontakt-URL.';
|
||||
return '';
|
||||
}
|
||||
|
||||
function refreshProfileSelect(selected = '') {
|
||||
const profiles = readStore(PROFILE_KEY);
|
||||
const select = $('profile-select');
|
||||
select.replaceChildren(new Option('Website-Profil wählen …', ''));
|
||||
Object.keys(profiles).sort((a, b) => a.localeCompare(b)).forEach(name => select.add(new Option(name, name)));
|
||||
if (selected && profiles[selected]) select.value = selected;
|
||||
}
|
||||
|
||||
function saveProfile() {
|
||||
const name = $('profile-name').value.trim();
|
||||
if (!name) {
|
||||
alert('Bitte einen Profilnamen angeben.');
|
||||
$('profile-name').focus();
|
||||
return;
|
||||
}
|
||||
const profile = profileFromForm();
|
||||
const error = validateProfile(profile);
|
||||
if (error) {
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
const profiles = readStore(PROFILE_KEY);
|
||||
profiles[name] = {values: profile, updatedAt: new Date().toISOString()};
|
||||
writeStore(PROFILE_KEY, profiles);
|
||||
refreshProfileSelect(name);
|
||||
}
|
||||
|
||||
function loadProfile(name) {
|
||||
if (!name) {
|
||||
emptyProfileForm();
|
||||
return;
|
||||
}
|
||||
const item = readStore(PROFILE_KEY)[name];
|
||||
if (!item || !item.values) return;
|
||||
$('profile-name').value = name;
|
||||
for (const [id, key] of profileFields) $(id).value = item.values[key] || '';
|
||||
}
|
||||
|
||||
function deleteProfile() {
|
||||
const name = $('profile-select').value;
|
||||
if (!name) return;
|
||||
const profiles = readStore(PROFILE_KEY);
|
||||
delete profiles[name];
|
||||
writeStore(PROFILE_KEY, profiles);
|
||||
refreshProfileSelect();
|
||||
emptyProfileForm();
|
||||
}
|
||||
|
||||
function activeQuery() {
|
||||
if (!activeTemplate) throw new Error('Bitte zuerst eine Kennzeichnungsvorlage laden.');
|
||||
const params = new URLSearchParams(activeTemplate);
|
||||
params.delete('subject');
|
||||
params.delete('link');
|
||||
const profile = profileFromForm();
|
||||
const error = validateProfile(profile);
|
||||
if (error) throw new Error(error);
|
||||
for (const [key, value] of Object.entries(profile)) params.set(key, value);
|
||||
const mergedError = validateMergedParams(params);
|
||||
if (mergedError) throw new Error(mergedError);
|
||||
return params;
|
||||
}
|
||||
|
||||
function csvCellLine(line) {
|
||||
let value = line.trim();
|
||||
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
||||
value = value.slice(1, -1).replaceAll('""', '"');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseSubjects() {
|
||||
$('url-messages').replaceChildren();
|
||||
let lines = $('url-input').value.split(/\r?\n/).map(csvCellLine).filter(Boolean);
|
||||
if (lines[0]?.toLowerCase() === 'url') lines = lines.slice(1);
|
||||
const baseRaw = $('base-url').value.trim();
|
||||
let base = null;
|
||||
if (baseRaw) {
|
||||
base = safeHTTPURL(baseRaw);
|
||||
if (!base) addMessage('Die Basis-URL ist ungültig und wird nicht verwendet.', 'error');
|
||||
}
|
||||
|
||||
const unique = new Map();
|
||||
const invalid = [];
|
||||
let duplicates = 0;
|
||||
for (const raw of lines) {
|
||||
let parsed = safeHTTPURL(raw);
|
||||
if (!parsed && base) {
|
||||
try {
|
||||
const candidate = new URL(raw, base);
|
||||
if (['http:', 'https:'].includes(candidate.protocol) && !candidate.username && !candidate.password) parsed = candidate;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!parsed) {
|
||||
invalid.push(raw);
|
||||
continue;
|
||||
}
|
||||
const normalized = parsed.href;
|
||||
if (unique.has(normalized)) {
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
unique.set(normalized, normalized);
|
||||
}
|
||||
const subjects = [...unique.values()];
|
||||
$('url-counter').querySelector('strong').textContent = String(subjects.length);
|
||||
if (duplicates) addMessage(`${duplicates} Duplikat${duplicates === 1 ? '' : 'e'} entfernt.`, 'info');
|
||||
if (invalid.length) addMessage(`${invalid.length} ungültige Zeile${invalid.length === 1 ? '' : 'n'} ignoriert: ${invalid.slice(0, 3).join(', ')}${invalid.length > 3 ? ' …' : ''}`, 'error');
|
||||
if (cfg && subjects.length > cfg.maxURLs) addMessage(`Maximal ${cfg.maxURLs} URLs pro Lauf. Bitte die Liste aufteilen.`, 'error');
|
||||
return subjects;
|
||||
}
|
||||
|
||||
function csvEscape(value) {
|
||||
const text = String(value ?? '');
|
||||
return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
}
|
||||
|
||||
function buildExports(results) {
|
||||
const successful = results.filter(item => item.ok && item.data);
|
||||
const html = successful.map(item => `<!-- ${item.subject} -->\n${item.data.embedHtml || item.data.html}`).join('\n\n');
|
||||
const markdown = successful.map(item => `### ${item.subject}\n\n${item.data.markdown}`).join('\n\n');
|
||||
const jsonArray = successful.map(item => item.data.jsonLd);
|
||||
const json = JSON.stringify(jsonArray, null, 2);
|
||||
const jsonl = successful.map(item => JSON.stringify(item.data.jsonLd)).join('\n');
|
||||
const csvRows = [['subject', 'declaration', 'manifest', 'badge']];
|
||||
successful.forEach(item => csvRows.push([item.subject, item.data.declarationUrl, item.data.manifestUrl, item.data.badgeUrl]));
|
||||
const csv = csvRows.map(row => row.map(csvEscape).join(',')).join('\n');
|
||||
return {html, markdown, json, jsonl, csv};
|
||||
}
|
||||
|
||||
function buildDisclosurePreview(data) {
|
||||
const wrap = document.createElement('p');
|
||||
wrap.className = 'bulk-disclosure-preview';
|
||||
const link = document.createElement('a');
|
||||
link.className = 'bulk-disclosure-preview__link';
|
||||
link.href = data.declarationUrl;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
|
||||
const badge = document.createElement('img');
|
||||
badge.className = 'bulk-disclosure-preview__badge';
|
||||
badge.src = data.badgeUrl;
|
||||
badge.alt = '';
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.className = 'bulk-disclosure-preview__text';
|
||||
text.textContent = data.accessibleText || 'KI-Nutzung – Details zur Kennzeichnung';
|
||||
|
||||
link.append(badge, text);
|
||||
wrap.appendChild(link);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
currentResults = results;
|
||||
exportsCache = buildExports(results);
|
||||
const successful = results.filter(item => item.ok).length;
|
||||
const failed = results.length - successful;
|
||||
$('results-section').hidden = false;
|
||||
$('result-summary').textContent = `${successful} Kennzeichnung${successful === 1 ? '' : 'en'} erzeugt${failed ? `, ${failed} Fehler` : ''}.`;
|
||||
const tbody = $('result-rows');
|
||||
tbody.replaceChildren();
|
||||
results.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
const subjectCell = document.createElement('td');
|
||||
const code = document.createElement('code');
|
||||
code.textContent = item.subject;
|
||||
subjectCell.appendChild(code);
|
||||
const previewCell = document.createElement('td');
|
||||
const statusCell = document.createElement('td');
|
||||
statusCell.className = item.ok ? 'ok' : 'failed';
|
||||
statusCell.textContent = item.ok ? '✓ erzeugt' : `Fehler: ${item.error || 'unbekannt'}`;
|
||||
const linksCell = document.createElement('td');
|
||||
if (item.ok && item.data) {
|
||||
previewCell.appendChild(buildDisclosurePreview(item.data));
|
||||
const links = document.createElement('div');
|
||||
links.className = 'result-links';
|
||||
[['Declaration', item.data.declarationUrl], ['JSON-LD', item.data.manifestUrl], ['SVG (nur Grafik)', item.data.badgeUrl]].forEach(([label, href]) => {
|
||||
const a = document.createElement('a');
|
||||
a.textContent = label;
|
||||
a.href = href;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
links.appendChild(a);
|
||||
});
|
||||
linksCell.appendChild(links);
|
||||
}
|
||||
tr.append(subjectCell, previewCell, statusCell, linksCell);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
$('output-html').value = exportsCache.html;
|
||||
$('output-markdown').value = exportsCache.markdown;
|
||||
$('output-json').value = exportsCache.json;
|
||||
$('output-csv').value = exportsCache.csv;
|
||||
$('results-section').scrollIntoView({behavior: 'smooth', block: 'start'});
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
let query;
|
||||
try {
|
||||
query = activeQuery();
|
||||
} catch (error) {
|
||||
setStatus(error.message, 'error');
|
||||
return;
|
||||
}
|
||||
const subjects = parseSubjects();
|
||||
if (!subjects.length) {
|
||||
addMessage('Bitte mindestens eine gültige URL einfügen.', 'error');
|
||||
return;
|
||||
}
|
||||
if (subjects.length > cfg.maxURLs) return;
|
||||
|
||||
const button = $('generate');
|
||||
const original = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = `${subjects.length} Kennzeichnungen werden erzeugt …`;
|
||||
try {
|
||||
const response = await fetch('/api/render-batch', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
|
||||
body: JSON.stringify({template: query.toString(), subjects})
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.detail || `HTTP ${response.status}`);
|
||||
renderResults(payload.results || []);
|
||||
} catch (error) {
|
||||
addMessage(`Bulk-Lauf fehlgeschlagen: ${error.message}`, 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = original;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyExport(kind, button) {
|
||||
const value = exportsCache[kind] || '';
|
||||
if (!value) return;
|
||||
await navigator.clipboard.writeText(value);
|
||||
const old = button.textContent;
|
||||
button.textContent = 'Kopiert';
|
||||
setTimeout(() => { button.textContent = old; }, 1300);
|
||||
}
|
||||
|
||||
function downloadExport(kind) {
|
||||
const spec = {
|
||||
html: ['ai-disclosure-bulk.html', 'text/html;charset=utf-8'],
|
||||
markdown: ['ai-disclosure-bulk.md', 'text/markdown;charset=utf-8'],
|
||||
json: ['ai-disclosure-bulk.json', 'application/ld+json;charset=utf-8'],
|
||||
jsonl: ['ai-disclosure-bulk.jsonl', 'application/x-ndjson;charset=utf-8'],
|
||||
csv: ['ai-disclosure-bulk.csv', 'text/csv;charset=utf-8']
|
||||
}[kind];
|
||||
const value = exportsCache[kind] || '';
|
||||
if (!spec || !value) return;
|
||||
downloadBlob(spec[0], value, spec[1]);
|
||||
}
|
||||
|
||||
function downloadBlob(filename, content, type) {
|
||||
const blob = new Blob([content], {type});
|
||||
const href = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(href), 500);
|
||||
}
|
||||
|
||||
function exportBrowserData() {
|
||||
const payload = {
|
||||
format: EXPORT_FORMAT,
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
profiles: readStore(PROFILE_KEY),
|
||||
templates: readStore(TEMPLATE_KEY)
|
||||
};
|
||||
downloadBlob('ai-disclosure-bulk-browser-data.json', JSON.stringify(payload, null, 2), 'application/json;charset=utf-8');
|
||||
}
|
||||
|
||||
async function importBrowserData(file) {
|
||||
if (!file) return;
|
||||
try {
|
||||
const payload = JSON.parse(await file.text());
|
||||
if (payload.format !== EXPORT_FORMAT || payload.version !== 1) throw new Error('Unbekanntes Exportformat.');
|
||||
if (!payload.profiles || typeof payload.profiles !== 'object' || !payload.templates || typeof payload.templates !== 'object') throw new Error('Export ist unvollständig.');
|
||||
writeStore(PROFILE_KEY, payload.profiles);
|
||||
writeStore(TEMPLATE_KEY, payload.templates);
|
||||
refreshProfileSelect();
|
||||
refreshTemplateSelect();
|
||||
alert('Profile und Vorlagen wurden importiert.');
|
||||
} catch (error) {
|
||||
alert(`Import fehlgeschlagen: ${error.message}`);
|
||||
} finally {
|
||||
$('import-browser-data').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function importFragmentTemplate() {
|
||||
if (!location.hash.startsWith('#')) return;
|
||||
const hash = new URLSearchParams(location.hash.slice(1));
|
||||
const raw = hash.get('template');
|
||||
if (!raw) return;
|
||||
try {
|
||||
const payload = JSON.parse(raw);
|
||||
if (payload.version !== 1 || typeof payload.query !== 'string') throw new Error('Unbekanntes Vorlagenformat.');
|
||||
const params = new URLSearchParams(payload.query);
|
||||
if (payload.theme) params.set('theme', payload.theme);
|
||||
activateTemplate(params, 'Aus dem Generator übernommen');
|
||||
$('template-name').value = payload.name || '';
|
||||
} catch (error) {
|
||||
setStatus(`Vorlage aus dem Generator konnte nicht übernommen werden: ${error.message}`, 'error');
|
||||
} finally {
|
||||
history.replaceState(null, '', location.pathname + location.search);
|
||||
}
|
||||
}
|
||||
|
||||
function clearBrowserData() {
|
||||
if (!confirm('Alle lokal gespeicherten Website-Profile und Kennzeichnungsvorlagen in diesem Browser löschen?')) return;
|
||||
localStorage.removeItem(PROFILE_KEY);
|
||||
localStorage.removeItem(TEMPLATE_KEY);
|
||||
refreshProfileSelect();
|
||||
refreshTemplateSelect();
|
||||
emptyProfileForm();
|
||||
setStatus('Lokale Profile und Vorlagen wurden gelöscht.');
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const response = await fetch('/api/config', {headers: {'Accept': 'application/json'}});
|
||||
cfg = await response.json();
|
||||
} catch (_) {
|
||||
cfg = {maxURLs: 500};
|
||||
}
|
||||
refreshTemplateSelect();
|
||||
refreshProfileSelect();
|
||||
importFragmentTemplate();
|
||||
parseSubjects();
|
||||
|
||||
$('load-template').addEventListener('click', () => {
|
||||
try {
|
||||
activateTemplate(parseTemplate($('template-input').value));
|
||||
} catch (error) {
|
||||
setStatus(error.message, 'error');
|
||||
}
|
||||
});
|
||||
$('save-template').addEventListener('click', saveTemplate);
|
||||
$('saved-template-select').addEventListener('change', event => loadSavedTemplate(event.target.value));
|
||||
$('delete-template').addEventListener('click', deleteTemplate);
|
||||
$('profile-select').addEventListener('change', event => loadProfile(event.target.value));
|
||||
$('new-profile').addEventListener('click', emptyProfileForm);
|
||||
$('save-profile').addEventListener('click', saveProfile);
|
||||
$('delete-profile').addEventListener('click', deleteProfile);
|
||||
$('export-browser-data').addEventListener('click', exportBrowserData);
|
||||
$('import-browser-data').addEventListener('change', event => importBrowserData(event.target.files?.[0]));
|
||||
$('clear-browser-data').addEventListener('click', clearBrowserData);
|
||||
$('url-input').addEventListener('input', parseSubjects);
|
||||
$('base-url').addEventListener('input', parseSubjects);
|
||||
$('clear-urls').addEventListener('click', () => {
|
||||
$('url-input').value = '';
|
||||
$('url-messages').replaceChildren();
|
||||
parseSubjects();
|
||||
});
|
||||
$('generate').addEventListener('click', generate);
|
||||
document.querySelectorAll('[data-copy]').forEach(button => button.addEventListener('click', () => copyExport(button.dataset.copy, button)));
|
||||
document.querySelectorAll('[data-download]').forEach(button => button.addEventListener('click', () => downloadExport(button.dataset.download)));
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
@@ -1,249 +0,0 @@
|
||||
{{define "index.html"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.Name}}</title>
|
||||
<meta name="description" content="Mehrere AI-Disclosure-Kennzeichnungen aus einer gemeinsamen Vorlage erzeugen.">
|
||||
<link rel="stylesheet" href="/static/bulk.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/">{{.Name}}</a>
|
||||
<nav>
|
||||
<a href="{{.GeneratorURL}}">Generator</a>
|
||||
<a href="{{.DisclosureBaseURL}}/datenschutz">Datenschutz</a>
|
||||
<a href="{{.DisclosureBaseURL}}/impressum">Impressum</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Bulk Generator</p>
|
||||
<h1>Einmal definieren.<br>Viele Inhalte kennzeichnen.</h1>
|
||||
<p class="lead">Übernimm eine Kennzeichnung aus dem normalen Generator, ergänze wiederkehrende Website-Daten und erzeuge HTML, Markdown und JSON-LD für bis zu {{.MaxURLs}} Inhalts-URLs in einem Lauf.</p>
|
||||
<div class="notice privacy-note"><strong>Datensparsam:</strong> Inhalts-URLs werden nicht abgerufen. Website-Profile und gespeicherte Vorlagen bleiben ausschließlich im Browser-Speicher dieses Geräts. URL-Listen und Ergebnisse werden nicht dauerhaft gespeichert.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel workflow" aria-labelledby="workflow-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">1 · Kennzeichnung</p>
|
||||
<h2 id="workflow-title">Vorlage festlegen</h2>
|
||||
<p>Am sichersten ist die Übernahme direkt aus dem bestehenden Generator. Alternativ kannst du eine Declaration-URL oder deren Query-String einfügen.</p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar wrap">
|
||||
<a class="button secondary" id="open-generator" href="{{.GeneratorURL}}" target="_blank" rel="noopener">Kennzeichnung im Generator definieren</a>
|
||||
<select id="saved-template-select" aria-label="Gespeicherte Vorlage">
|
||||
<option value="">Gespeicherte Vorlage wählen …</option>
|
||||
</select>
|
||||
<button type="button" class="button subtle" id="delete-template">Vorlage löschen</button>
|
||||
</div>
|
||||
|
||||
<label class="field wide">Declaration-URL oder Query-String
|
||||
<textarea id="template-input" rows="5" spellcheck="false" placeholder="https://…/declaration?mode=article&textExtent=partial&…"></textarea>
|
||||
<small>`subject` wird beim Laden entfernt; die jeweilige Inhalts-URL setzt der Bulk-Lauf später selbst.</small>
|
||||
</label>
|
||||
|
||||
<div class="toolbar wrap">
|
||||
<button type="button" id="load-template">Vorlage laden</button>
|
||||
<label class="inline-field">Vorlagenname
|
||||
<input id="template-name" maxlength="80" placeholder="z. B. Artikel – redaktionell geprüft">
|
||||
</label>
|
||||
<button type="button" class="button secondary" id="save-template">Im Browser speichern</button>
|
||||
</div>
|
||||
|
||||
<div id="template-status" class="status" role="status" aria-live="polite">Noch keine Vorlage geladen.</div>
|
||||
<details class="details-box">
|
||||
<summary>Aktive Parameter anzeigen</summary>
|
||||
<dl id="template-params" class="parameter-list"></dl>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="profile-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">2 · Wiederkehrende Daten</p>
|
||||
<h2 id="profile-title">Website-Profil</h2>
|
||||
<p>Hier gehören Angaben hinein, die du nicht bei jedem Batch erneut eingeben möchtest. Nicht ausgefüllte Profilfelder verändern die geladene Kennzeichnungsvorlage nicht.</p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar wrap">
|
||||
<select id="profile-select" aria-label="Website-Profil">
|
||||
<option value="">Website-Profil wählen …</option>
|
||||
</select>
|
||||
<button type="button" class="button subtle" id="new-profile">Neues Profil</button>
|
||||
<button type="button" class="button subtle danger" id="delete-profile">Profil löschen</button>
|
||||
</div>
|
||||
|
||||
<div class="profile-grid">
|
||||
<label>Profilname
|
||||
<input id="profile-name" maxlength="80" placeholder="z. B. example.org">
|
||||
</label>
|
||||
<label>Sprache
|
||||
<select id="profile-lang">
|
||||
<option value="">Aus Vorlage übernehmen</option>
|
||||
<option value="de">Deutsch</option><option value="en">English</option><option value="fr">Français</option>
|
||||
<option value="es">Español</option><option value="it">Italiano</option><option value="nl">Nederlands</option>
|
||||
<option value="pt">Português</option><option value="pl">Polski</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>AI-Act-Rolle
|
||||
<select id="profile-role">
|
||||
<option value="">Aus Vorlage übernehmen</option>
|
||||
<option value="deployer">Deployer</option>
|
||||
<option value="provider">Provider</option>
|
||||
<option value="both">Beides</option>
|
||||
<option value="unsure">Unklar</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Nutzungskontext
|
||||
<select id="profile-use-context">
|
||||
<option value="">Aus Vorlage übernehmen</option>
|
||||
<option value="professional">Beruflich / organisatorisch</option>
|
||||
<option value="personalNonProfessional">Rein persönlich / nicht beruflich</option>
|
||||
<option value="unsure">Unklar</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Nachweisgrundlage
|
||||
<select id="profile-assurance">
|
||||
<option value="">Aus Vorlage übernehmen</option>
|
||||
<option value="selfDeclared">Selbstauskunft</option>
|
||||
<option value="technicallyRecorded">Technisch erfasst</option>
|
||||
<option value="signed">Signiert</option>
|
||||
<option value="verified">Verifiziert</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="subsection wide">
|
||||
<h3>Autor / Byline <span>optional</span></h3>
|
||||
<div class="profile-grid nested">
|
||||
<label>Autor/in
|
||||
<input id="profile-author" maxlength="200" placeholder="Name">
|
||||
</label>
|
||||
<label>Autoren-URL
|
||||
<input id="profile-author-url" type="url" placeholder="https://example.org/autor">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subsection wide">
|
||||
<h3>Redaktionelle Verantwortung <span>nur eintragen, wenn sie zur Vorlage passt</span></h3>
|
||||
<div class="profile-grid nested">
|
||||
<label>Rolle
|
||||
<select id="profile-responsible-role">
|
||||
<option value="">Aus Vorlage übernehmen / keine</option>
|
||||
<option value="publisher">Publisher</option>
|
||||
<option value="other">Andere verantwortliche Stelle</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Name / Organisation
|
||||
<input id="profile-responsible" maxlength="200" placeholder="Redaktion / Organisation">
|
||||
</label>
|
||||
<label class="wide">Impressum / Verantwortlichkeits-URL
|
||||
<input id="profile-responsible-url" type="url" placeholder="https://example.org/impressum">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subsection wide">
|
||||
<h3>Beschwerde- / Rückmeldestelle <span>Best Practice</span></h3>
|
||||
<div class="profile-grid nested">
|
||||
<label>Name
|
||||
<input id="profile-complaint-name" maxlength="200" placeholder="Redaktion / Ombudsstelle">
|
||||
</label>
|
||||
<label>E-Mail
|
||||
<input id="profile-complaint-email" type="email" placeholder="feedback@example.org">
|
||||
</label>
|
||||
<label class="wide">Kontakt-URL
|
||||
<input id="profile-complaint-url" type="url" placeholder="https://example.org/kontakt">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar wrap">
|
||||
<button type="button" id="save-profile">Profil speichern</button>
|
||||
<button type="button" class="button secondary" id="export-browser-data">Profile & Vorlagen exportieren</button>
|
||||
<label class="button secondary file-button">Importieren<input id="import-browser-data" type="file" accept="application/json,.json"></label>
|
||||
<button type="button" class="button subtle danger" id="clear-browser-data">Alle lokalen Daten löschen</button>
|
||||
</div>
|
||||
<p class="help">Gespeichert werden nur die von dir angelegten Profile und Vorlagen in <code>localStorage</code>. Eine Synchronisierung zum Server findet nicht statt.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="urls-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">3 · Inhalte</p>
|
||||
<h2 id="urls-title">URLs oder Pfade einfügen</h2>
|
||||
<p>Eine URL bzw. ein Pfad pro Zeile. Bei relativen Pfaden wird die optionale Basis-URL verwendet. Eine einspaltige CSV-Datei mit der Überschrift <code>url</code> kann ebenfalls direkt eingefügt werden.</p>
|
||||
</div>
|
||||
<div class="url-grid">
|
||||
<label>Basis-URL für relative Pfade
|
||||
<input id="base-url" type="url" placeholder="https://example.org">
|
||||
</label>
|
||||
<div class="counter-card" id="url-counter" aria-live="polite"><strong>0</strong><span>gültige URLs</span></div>
|
||||
</div>
|
||||
<label class="field wide">URL-Liste
|
||||
<textarea id="url-input" rows="12" spellcheck="false" placeholder="/artikel/eins /artikel/zwei https://other.example/artikel/drei"></textarea>
|
||||
</label>
|
||||
<div id="url-messages" class="messages" aria-live="polite"></div>
|
||||
<div class="toolbar wrap">
|
||||
<button type="button" id="generate">Kennzeichnungen erzeugen</button>
|
||||
<button type="button" class="button subtle" id="clear-urls">URL-Liste leeren</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel results-panel" id="results-section" aria-labelledby="results-title" hidden>
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">4 · Ausgabe</p>
|
||||
<h2 id="results-title">Ergebnisse</h2>
|
||||
<p id="result-summary"></p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar wrap export-buttons">
|
||||
<button type="button" data-copy="html">Komplettes HTML kopieren</button>
|
||||
<button type="button" class="button secondary" data-download="html">HTML + JSON-LD herunterladen</button>
|
||||
<button type="button" data-copy="markdown">Markdown kopieren</button>
|
||||
<button type="button" class="button secondary" data-download="markdown">Markdown herunterladen</button>
|
||||
<button type="button" data-copy="json">JSON-LD kopieren</button>
|
||||
<button type="button" class="button secondary" data-download="json">JSON-LD herunterladen</button>
|
||||
<button type="button" class="button secondary" data-download="jsonl">JSONL herunterladen</button>
|
||||
<button type="button" class="button secondary" data-download="csv">CSV herunterladen</button>
|
||||
</div>
|
||||
|
||||
<p class="embed-note">Der HTML-Export ist die empfohlene Ein-Schritt-Integration: sichtbarer, tastaturerreichbarer Hinweis mit Text neben dem Badge plus eingebettetes JSON-LD. Die Vorschau unten zeigt genau diese sichtbare Kennzeichnung. Der Link „SVG (nur Grafik)“ dient nur zum separaten Abruf der Bilddatei und ist nicht die empfohlene alleinstehende Integration.</p>
|
||||
|
||||
<div class="result-table-wrap">
|
||||
<table class="result-table">
|
||||
<thead><tr><th>Inhalt</th><th>Kennzeichnung</th><th>Status</th><th>Links</th></tr></thead>
|
||||
<tbody id="result-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="output-grid">
|
||||
<label>Komplettes HTML inkl. JSON-LD<textarea id="output-html" rows="12" readonly></textarea></label>
|
||||
<label>Markdown<textarea id="output-markdown" rows="12" readonly></textarea></label>
|
||||
<label>JSON-LD<textarea id="output-json" rows="12" readonly></textarea></label>
|
||||
<label>CSV<textarea id="output-csv" rows="8" readonly></textarea></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel compact-panel">
|
||||
<h2>Was dieser Container bewusst nicht tut</h2>
|
||||
<ul class="plain-list">
|
||||
<li>keine Benutzerkonten und keine Datenbank</li>
|
||||
<li>kein Crawling und kein Abruf der eingegebenen Inhalts-URLs</li>
|
||||
<li>keine eigene rechtliche Entscheidungslogik</li>
|
||||
<li>keine dauerhafte Speicherung von URL-Listen oder Ergebnissen</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>AI Disclosure Bulk · {{.Version}} · Core-Ausgabe über {{.DisclosureBaseURL}}</span>
|
||||
<nav><a href="{{.DisclosureBaseURL}}/datenschutz">Datenschutz</a><a href="{{.DisclosureBaseURL}}/impressum">Impressum</a></nav>
|
||||
</footer>
|
||||
<script src="/static/bulk.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
+2
-56
@@ -1,6 +1,5 @@
|
||||
services:
|
||||
app:
|
||||
<<<<<<< HEAD
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -30,12 +29,6 @@ services:
|
||||
BULK_API_KEY: "${BULK_API_KEY:-}"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
=======
|
||||
image: git.send.nrw/sendnrw/ai-disclosure-standard:latest
|
||||
container_name: ai
|
||||
env_file:
|
||||
- .env
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
volumes:
|
||||
- license-cache:/data
|
||||
read_only: true
|
||||
@@ -47,58 +40,11 @@ services:
|
||||
- ALL
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- /ai-disclosure
|
||||
- --healthcheck
|
||||
test: ["CMD", "/ai-disclosure", "--healthcheck"]
|
||||
interval: 15s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
<<<<<<< HEAD
|
||||
|
||||
=======
|
||||
networks:
|
||||
- proxy
|
||||
bulk:
|
||||
image: git.send.nrw/sendnrw/ai-disclosure-standard:bulk-latest
|
||||
container_name: ai-bulk
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
CORE_INTERNAL_URL: http://app:8080
|
||||
DISCLOSURE_BASE_URL: ${BASE_URL:-http://localhost:8080}
|
||||
GENERATOR_URL: ${BASE_URL:-http://localhost:8080}
|
||||
BULK_PUBLIC_NAME: ${PUBLIC_NAME:-AI Disclosure Bulk}
|
||||
BULK_MAX_URLS: ${BULK_MAX_URLS:-500}
|
||||
BULK_WORKERS: ${BULK_WORKERS:-4}
|
||||
ports:
|
||||
- 8088:8081
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=8m,mode=1777
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- /ai-disclosure-bulk
|
||||
- --healthcheck
|
||||
interval: 15s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- proxy
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
volumes:
|
||||
license-cache: null
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
license-cache:
|
||||
|
||||
@@ -32,11 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: app
|
||||
<<<<<<< HEAD
|
||||
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:2.0.0
|
||||
=======
|
||||
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.8.2
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
app:
|
||||
<<<<<<< HEAD
|
||||
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:2.0.0
|
||||
=======
|
||||
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.8.2
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
environment:
|
||||
BASE_URL: https://ai.example.org
|
||||
PUBLIC_NAME: AI Usage Disclosure
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# Mehrsprachige Hintergrundseite
|
||||
|
||||
<<<<<<< HEAD
|
||||
Version 2.0.0 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit.
|
||||
=======
|
||||
Version 1.8.2 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit.
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
|
||||
## Routen
|
||||
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
# EU AI Act: Compliance-Grenzen und Betreiber-Checkliste
|
||||
|
||||
**Rechtsstand: 22. Juli 2026**
|
||||
|
||||
Diese Datei beschreibt, welche Teile des EU AI Act (Verordnung (EU) 2024/1689) dieses Projekt technisch unterstützen kann und welche Pflichten außerhalb seines Funktionsumfangs liegen. Sie ersetzt keine Prüfung des konkreten Einsatzes.
|
||||
|
||||
## 1. Was dieses Projekt ist
|
||||
|
||||
Die mitgelieferte Anwendung erzeugt deterministisch SVG-Hinweise, Erklärungsseiten und JSON-LD aus den vom Nutzer eingegebenen Angaben. Sie enthält selbst kein KI-Modell und führt keine Modellinferenz aus.
|
||||
|
||||
Der AI Act erfasst nur Systeme, die unter die Definition eines „AI system“ in Artikel 3 Absatz 1 fallen. Ob eine konkrete, veränderte oder erweiterte Installation diese Definition erfüllt, ist anhand der tatsächlichen technischen Funktion zu prüfen.
|
||||
|
||||
Offizielle Quelle:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-3
|
||||
|
||||
## 2. Dieses Projekt ist keine vollständige AI-Act-Compliance-Lösung
|
||||
|
||||
Der Generator unterstützt vor allem die **sichtbare und dokumentierte Offenlegung von KI-Nutzung**. Er kann insbesondere bei freiwilliger Transparenz und bei bestimmten Betreiberpflichten aus Artikel 50 Absatz 4 helfen.
|
||||
|
||||
Er erfüllt oder prüft **nicht automatisch**:
|
||||
|
||||
- die Anbieterpflicht zur technischen, maschinenlesbaren Markierung von generierten oder manipulierten Ausgaben nach Artikel 50 Absatz 2;
|
||||
- die Transparenzpflicht bei direkter Mensch-KI-Interaktion nach Artikel 50 Absatz 1;
|
||||
- Informationspflichten bei Emotionserkennung oder biometrischer Kategorisierung nach Artikel 50 Absatz 3;
|
||||
- die Pflicht zur AI Literacy nach Artikel 4;
|
||||
- Verbote nach Artikel 5;
|
||||
- die Einstufung und Pflichten für Hochrisiko-KI nach Kapitel III;
|
||||
- Pflichten für Anbieter von General-Purpose-AI-Modellen nach Kapitel V;
|
||||
- sonstige Pflichten aus Datenschutz-, Urheber-, Verbraucher-, Medien- oder Produktsicherheitsrecht.
|
||||
|
||||
## 3. Artikel 50: sichtbare Offenlegung durch Betreiber
|
||||
|
||||
Artikel 50 gilt ab **2. August 2026**. Betreiber bestimmter generativer KI-Systeme müssen insbesondere Deepfakes und bestimmte KI-generierte oder manipulierte Texte zu Angelegenheiten von öffentlichem Interesse offenlegen.
|
||||
|
||||
Für solche sichtbaren Offenlegungen gilt nach Artikel 50 Absatz 5 und den Leitlinien der Kommission insbesondere:
|
||||
|
||||
1. Der Hinweis muss **klar und unterscheidbar** sein.
|
||||
2. Er muss **spätestens bei der ersten Exposition** gegenüber der betroffenen natürlichen Person erscheinen.
|
||||
3. Er muss die geltenden Anforderungen an **Barrierefreiheit** berücksichtigen.
|
||||
4. Bei Deepfakes genügt eine nur maschinenlesbare Markierung des Anbieters nicht als sichtbare Betreiber-Offenlegung.
|
||||
|
||||
Daher gilt für die Einbettung dieses Projekts:
|
||||
|
||||
- den empfohlenen `embedHtml`-Baustein oder eine gleichwertige Umsetzung unmittelbar am betroffenen Inhalt platzieren;
|
||||
- nicht ausschließlich im Footer, Impressum oder auf einer erst später erreichbaren Unterseite;
|
||||
- sichtbaren, verständlichen Text als Bestandteil des Links bereitstellen, damit die Offenlegung nicht nur über eine Grafik wahrnehmbar ist;
|
||||
- das Badge im empfohlenen Embed mit `alt=""`/`aria-hidden="true"` dekorativ behandeln, weil derselbe Bedeutungsgehalt bereits als sichtbarer Linktext vorhanden ist und sonst doppelt angesagt würde;
|
||||
- bei einer alleinstehenden Badge-Grafik weiterhin einen aussagekräftigen Alternativtext bereitstellen;
|
||||
- ausreichende Kontraste und ein ausreichend großes, tastaturerreichbares Linkziel sicherstellen. Der mitgelieferte Renderer wählt bei flachen Badges automatisch Schwarz/Weiß für mindestens 4,5:1 Textkontrast und korrigiert Emoji-/Icon-Farben unter 3:1.
|
||||
|
||||
Offizielle Quellen:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
|
||||
- https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act
|
||||
- https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content
|
||||
|
||||
## 4. Menschliche Prüfung und redaktionelle Kontrolle
|
||||
|
||||
Für bestimmte veröffentlichte KI-generierte oder manipulierte Texte zu Angelegenheiten von öffentlichem Interesse kann die Kennzeichnungspflicht nach Artikel 50 Absatz 4 entfallen, wenn die gesetzlichen Voraussetzungen tatsächlich erfüllt sind.
|
||||
|
||||
Die Kommission stellt klar:
|
||||
|
||||
- „human review“ erfordert eine bewusste inhaltliche Prüfung durch Personen mit einschlägigem Wissen und professionellem Urteil;
|
||||
- „editorial control“ setzt tatsächliche inhaltliche Entscheidungsbefugnis voraus, also insbesondere die Möglichkeit, Inhalte aus sachlichen Gründen zu genehmigen, zu ändern oder abzulehnen;
|
||||
- rein oberflächliche, formale oder prozedurale Prüfungen wie Rechtschreib- oder Grammatikprüfung genügen nicht;
|
||||
- zusätzlich muss eine natürliche oder juristische Person die redaktionelle Verantwortung für die Veröffentlichung tragen.
|
||||
|
||||
Aus diesem Grund setzt der Generator seit dieser Härtung **keine menschliche oder redaktionelle Prüfung mehr automatisch voraus**. Der Nutzer muss den tatsächlich durchgeführten Prozess ausdrücklich auswählen.
|
||||
|
||||
Die Felder `editorialResponsibility.role`, `editorialResponsibility.name` und `editorialResponsibility.url` dienen nur der Dokumentation. Der Generator berücksichtigt eine mögliche Ausnahme nur, wenn mindestens Rolle und verantwortliche Person/Organisation ausdrücklich angegeben sind. Auch diese Dokumentation beweist nicht automatisch, dass die gesetzlichen Voraussetzungen tatsächlich vorliegen.
|
||||
|
||||
**Autor/Byline und redaktionelle Verantwortung sind getrennt.** `author` beschreibt die Urheberschaft bzw. Veröffentlichungs-Byline. `editorialResponsibility` beschreibt dagegen diejenige natürliche oder juristische Person, die letztlich die rechtliche redaktionelle Verantwortung für die Veröffentlichung trägt. Beides kann zusammenfallen, muss es aber nicht.
|
||||
|
||||
## 5. Besondere Ausnahme für gesetzlich autorisierte Strafverfolgungsnutzung
|
||||
|
||||
Artikel 50 Absatz 4 enthält für Deepfakes und Public-Interest-Texte eine besondere Ausnahme, soweit die konkrete Nutzung **gesetzlich zur Aufdeckung, Verhütung, Ermittlung oder Verfolgung von Straftaten autorisiert** ist.
|
||||
|
||||
Schema 1.3 kann diese Selbsteinordnung in `legalContext.lawEnforcementAuthorization` (`yes`, `no`, `unsure`) dokumentieren. Das Feld erscheint im Generator nur, wenn zuvor eine Deepfake- oder Public-Interest-Konstellation ausgewählt wurde. Ein bloßer Behördenstatus genügt nicht als Grundlage für „yes“; die konkrete gesetzliche Autorisierung des konkreten Einsatzes sollte intern nachvollziehbar dokumentiert sein. Bei `unsure` warnt der Generator davor, die Ausnahme ohne weitere Prüfung zugrunde zu legen.
|
||||
|
||||
Offizielle Quelle:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
|
||||
|
||||
## 6. Beschwerde-/Rückmeldestelle: Best Practice, nicht Art.-50-Pflicht
|
||||
|
||||
Eine eigene interne Beschwerde- oder Rückmeldestelle für jeden gekennzeichneten Inhalt ist **keine allgemeine Pflicht aus Artikel 50**. Das Projekt bietet `complaintsContact` deshalb ausdrücklich als Best-Practice-Metadatum an und trennt es von rechtlich erforderlichen Feldern.
|
||||
|
||||
Daneben enthält der AI Act in Artikel 85 ein eigenständiges Recht, bei einer Marktüberwachungsbehörde Beschwerde über einen Verstoß gegen den AI Act einzulegen. Eine freiwillige interne Anlaufstelle des Publishers ersetzt dieses gesetzliche Beschwerderecht nicht. Zusätzlich können Medien-, Verbraucher-, Plattform-, Datenschutz- oder andere Fachregeln eigene Kontakt- oder Beschwerdepflichten begründen.
|
||||
|
||||
Offizielle Quellen:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-85
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
|
||||
|
||||
## 7. Mögliche Konsequenzen bei Verstößen gegen Artikel 50
|
||||
|
||||
Artikel 99 Absatz 4 ordnet Verstöße gegen die Transparenzpflichten des Artikels 50 grundsätzlich einem Bußgeldrahmen von **bis zu 15 Mio. EUR** oder – bei Unternehmen – **bis zu 3 % des weltweiten Jahresgesamtumsatzes des vorangegangenen Geschäftsjahres** zu. Für KMU einschließlich Start-ups enthält Artikel 99 Absatz 6 eine besondere Deckelungsregel; außerdem müssen Art, Schwere, Dauer, Verantwortungsgrad und weitere Umstände des Einzelfalls berücksichtigt werden.
|
||||
|
||||
Der Generator zeigt diesen Rahmen als Risikohinweis, aber **nicht als Prognose einer konkreten Geldbuße**. Ob ein Verstoß vorliegt, welche Maßnahmen angemessen sind und ob bzw. in welcher Höhe eine Geldbuße verhängt wird, ist Sache der zuständigen Behörden bzw. Gerichte.
|
||||
|
||||
Offizielle Quelle:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-99
|
||||
|
||||
## 8. Artikel 50 Absatz 2: JSON-LD ist kein Ersatz für Provider-Marking
|
||||
|
||||
Anbieter generativer KI-Systeme müssen in den erfassten Fällen dafür sorgen, dass generierte oder manipulierte Inhalte maschinenlesbar markiert und als künstlich generiert oder manipuliert erkennbar sind. Nach Artikel 50 Absatz 2 müssen die technischen Lösungen wirksam, interoperabel, robust und zuverlässig sein, soweit dies technisch machbar ist.
|
||||
|
||||
Artikel 50 Absatz 2 nimmt Systeme aus, die lediglich Standard-Bearbeitungsfunktionen ausführen oder die vom Betreiber bereitgestellten Eingabedaten bzw. deren Semantik nicht wesentlich verändern. Ob diese Ausnahme tatsächlich greift, hängt von der konkreten Funktion und Veränderungswirkung ab; der sichtbare Generator kann diese technische Anbieterfrage nicht automatisch entscheiden.
|
||||
|
||||
Das von diesem Projekt erzeugte **JSON-LD ist ergänzende Dokumentation**. Es ist nicht als Ersatz für die technische Markierung gedacht, die der Anbieter des generativen KI-Systems in oder an der Ausgabe implementieren muss. Der Endpunkt `/v1/render` kann es über `embedHtml` direkt zusammen mit der sichtbaren Offenlegung als `<script type="application/ld+json">` ausgeben; dadurch lässt sich die menschlich sichtbare und die ergänzende strukturierte Information in einem Integrationsschritt einbauen, ohne beide Pflichten begrifflich gleichzusetzen.
|
||||
|
||||
Wer selbst Anbieter eines generativen KI-Systems im Sinne des AI Act ist, muss deshalb zusätzlich eine geeignete Provider-Marking-Lösung umsetzen und dokumentieren. Der von der Kommission positiv bewertete Code of Practice kann hierfür als freiwilliger Compliance-Rahmen genutzt werden.
|
||||
|
||||
Offizielle Quellen:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
|
||||
- https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content
|
||||
- https://digital-strategy.ec.europa.eu/en/library/commission-opinion-assessment-code-practice-transparency-ai-generated-content
|
||||
|
||||
## 9. Artikel 4: AI Literacy
|
||||
|
||||
Artikel 4 gilt bereits seit **2. Februar 2025**. Anbieter und Betreiber von KI-Systemen müssen nach besten Kräften Maßnahmen treffen, um ein ausreichendes Maß an AI Literacy bei Mitarbeitenden und sonstigen Personen sicherzustellen, die in ihrem Auftrag mit Betrieb oder Nutzung von KI-Systemen befasst sind.
|
||||
|
||||
Ein Badge oder eine Veröffentlichungserklärung erfüllt diese Organisationspflicht nicht. Betreiber sollten mindestens dokumentieren:
|
||||
|
||||
- welche KI-Systeme eingesetzt werden;
|
||||
- welche Rollen sie bedienen;
|
||||
- welche Kenntnisse und Schulungen erforderlich sind;
|
||||
- welche internen Regeln für Prüfung, Freigabe und Eskalation gelten;
|
||||
- wann Schulungen oder Richtlinien zuletzt aktualisiert wurden.
|
||||
|
||||
Offizielle Quelle:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-4
|
||||
|
||||
## 10. Risiko- und Rollenprüfung vor Produktivnutzung
|
||||
|
||||
Vor dem Einsatz sollte die Organisation dokumentieren:
|
||||
|
||||
1. Ist die eingesetzte Software überhaupt ein KI-System im Sinne von Artikel 3 Absatz 1?
|
||||
2. Welche Rolle liegt vor: Anbieter, Betreiber, Importeur, Distributor oder Produkthersteller?
|
||||
3. Fällt das konkrete KI-System unter ein Verbot des Artikels 5?
|
||||
4. Ist es ein Hochrisiko-KI-System nach Artikel 6 bzw. Anhang III?
|
||||
5. Handelt es sich um ein General-Purpose-AI-Modell oder ein darauf basierendes System mit zusätzlichen Pflichten?
|
||||
6. Greift eine Transparenzpflicht aus Artikel 50?
|
||||
7. Welche anderen Rechtsgebiete sind zusätzlich betroffen, insbesondere DSGVO, Urheberrecht, Verbraucher- und Medienrecht?
|
||||
|
||||
Offizielle Einstiegsquellen:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-2
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-5
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-6
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/annex-3
|
||||
|
||||
## 11. Zeitlicher Stand
|
||||
|
||||
Nach Artikel 113 gilt der AI Act grundsätzlich ab 2. August 2026; einzelne Teile gelten bereits früher. Insbesondere gelten Kapitel I und II, einschließlich Artikel 4 und der Verbote des Artikels 5, seit 2. Februar 2025.
|
||||
|
||||
Für Artikel 50 beginnen die Transparenzpflichten am 2. August 2026. Die Kommission weist für bestimmte bereits vor diesem Datum in Verkehr gebrachte generative KI-Systeme auf eine begrenzte Übergangsregel für die Anbieter-Markierung nach Artikel 50 Absatz 2 bis 2. Dezember 2026 hin.
|
||||
|
||||
Offizielle Quellen:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-113
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/timeline/timeline-implementation-eu-ai-act
|
||||
- https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act
|
||||
|
||||
## 12. Praktische Mindestregel für dieses Projekt
|
||||
|
||||
Wenn ein Inhalt **möglicherweise** unter Artikel 50 Absatz 4 fällt, ist die rechtlich defensivere Konfiguration:
|
||||
|
||||
- KI-Nutzung wahrheitsgemäß als `partial`, `mostly` oder `full` angeben;
|
||||
- die konkrete Tätigkeit angeben;
|
||||
- `humanReview` nur dann auf `editorial` oder `expert` setzen, wenn eine tatsächlich substanzielle inhaltliche Prüfung stattgefunden hat;
|
||||
- die redaktionell verantwortliche Person oder Organisation dokumentieren, wenn dies zutrifft;
|
||||
- einen sichtbaren textlichen Hinweis oder ein verständliches Standard-Badge spätestens bei der ersten Exposition platzieren;
|
||||
- das Emoji nicht als alleinige gesetzliche Kennzeichnung verwenden;
|
||||
- das JSON-LD nur als zusätzliche Dokumentation behandeln.
|
||||
|
||||
Bei Zweifeln über die Ausnahme wegen menschlicher Prüfung ist eine **zusätzliche Offenlegung regelmäßig die risikoärmere technische Entscheidung**, solange dadurch keine anderen rechtlichen Pflichten verletzt werden.
|
||||
|
||||
## 13. Strukturierter rechtlicher Kontext in Schema 1.3
|
||||
|
||||
Schema 1.3 erweitert die rechtliche Selbsteinordnung und trennt sie von allgemeinen Veröffentlichungsmetadaten.
|
||||
|
||||
`legalContext` unterstützt:
|
||||
|
||||
- `categories`: `deepfake`, `publicInterestText`, `artisticCreativeSatiricalFictional`, `otherVoluntary`;
|
||||
- `actorRole`: `deployer`, `provider`, `both`, `unsure`;
|
||||
- `useContext`: `professional`, `personalNonProfessional`, `unsure`;
|
||||
- `outputDate`: Datum des betroffenen KI-Outputs für die zeitliche Einordnung;
|
||||
- `deepfakeAssessment`: `yes`, `no`, `unsure` für die Deepfake-Einordnung, wenn KI-beteiligte Bild-/Audio-/Videoinhalte vorliegen;
|
||||
- `publicInterestAssessment`: `yes`, `no`, `unsure` für die Public-Interest-Text-Einordnung, wenn KI-beteiligter Text vorliegt;
|
||||
- `creativeWorkAssessment`: `yes`, `no`, `unsure` für die kreative/satirische/fiktionale Sonderform, wenn zuvor ein Deepfake bejaht wurde;
|
||||
- `lawEnforcementAuthorization`: `yes`, `no`, `unsure` für die besondere Ausnahme des Art. 50 Abs. 4.
|
||||
|
||||
Daneben stehen getrennt:
|
||||
|
||||
- `author`: Autor/in bzw. Byline und optionale Profil-URL;
|
||||
- `editorialResponsibility`: Rolle, verantwortliche Person/Organisation und optionale Nachweis-/Impressums-URL;
|
||||
- `complaintsContact`: freiwillige Beschwerde-/Feedback-Anlaufstelle mit Name sowie E-Mail und/oder URL.
|
||||
|
||||
### Abhängigkeitslogik im Generator
|
||||
|
||||
Der Generator zeigt Felder nur in sachlich passenden Konstellationen:
|
||||
|
||||
1. **Keine KI-Beteiligung:** rechtlicher Art.-50-Kontext, Review- und Tätigkeitsfelder werden ausgeblendet bzw. nicht übertragen.
|
||||
2. **Rolle und Nutzungskontext zuerst:** bei KI-Beteiligung werden zunächst AI-Act-Rolle und beruflicher/persönlicher Nutzungskontext verlangt. Bei rein persönlicher nicht-beruflicher Nutzung oder ausschließlich `provider`-seitiger Rolle werden die deployerspezifischen Inhaltsfragen nicht eingeblendet.
|
||||
3. **KI-Bild/Audio/Video:** erst danach wird eine verpflichtende `yes`/`no`/`unsure`-Deepfake-Prüfung sichtbar; nach `yes`/`unsure` wird zunächst eine mögliche Strafverfolgungs-Ausnahme geprüft. Die kreative/satirische/fiktionale Sonderform erscheint bei bestätigtem Deepfake erst, wenn diese Ausnahme nicht als einschlägig angegeben wurde.
|
||||
4. **KI-Text:** erst danach wird eine verpflichtende `yes`/`no`/`unsure`-Public-Interest-Prüfung sichtbar.
|
||||
5. **Deepfake/Public-Interest = `yes` oder `unsure`:** die Frage nach gesetzlich autorisierter Strafverfolgungsnutzung wird sichtbar.
|
||||
6. **Public-Interest + substanzielle Prüfung:** Felder zur redaktionellen Verantwortung werden eingeblendet, sofern nicht bereits die Strafverfolgungs-Ausnahme angegeben wurde und die Nutzung nicht rein persönlich bzw. ausschließlich providerseitig ist.
|
||||
7. **Autor/Byline:** erscheint nur bei KI-Beteiligung in artikelförmiger bzw. textbezogener Nutzung; eine Profil-URL erst nach Eingabe eines Namens.
|
||||
8. **Beschwerde-/Rückmeldestelle:** erscheint nur in rechtlich sensiblen Deepfake-/Public-Interest-Konstellationen und ist als Best Practice gekennzeichnet.
|
||||
9. **Unvollständige Pflichtfragen:** solange eine aktuell erforderliche Selbsteinordnung leer ist, gibt die Vorschau bewusst noch kein „Pflicht / keine Pflicht“-Ergebnis aus.
|
||||
|
||||
### Plausibilitäts- und Rechtswarnungen
|
||||
|
||||
Der Generator warnt unter anderem bei:
|
||||
|
||||
- nur formaler (`basic`) Prüfung in einer Public-Interest-Konstellation;
|
||||
- substantieller Prüfung ohne vollständig benannte redaktionelle Verantwortung;
|
||||
- Provider-Rolle, weil ein Veröffentlichungs-Badge Art. 50 Abs. 2 nicht ersetzt;
|
||||
- rein persönlicher, nicht beruflicher Nutzung;
|
||||
- Output-Datum vor dem 2. August 2026;
|
||||
- unklarer Strafverfolgungs-Autorisierung;
|
||||
- widersprüchlicher Kombination von Deepfake/Public-Interest und den strukturierten Inhaltsbestandteilen;
|
||||
- unvollständiger freiwilliger Beschwerde-/Rückmeldestelle.
|
||||
|
||||
Das System formuliert bewusst vorsichtig („spricht vieles dafür“, „kann in Betracht kommen“) und gibt keine verbindliche Rechtsentscheidung aus. Bei Grenzfällen sollte die tatsächliche Rechtslage des konkreten Einsatzes individuell geprüft werden.
|
||||
@@ -1,149 +0,0 @@
|
||||
# Rechtlicher und sicherer Produktivbetrieb
|
||||
|
||||
Stand: 20. Juli 2026
|
||||
|
||||
Diese Datei beschreibt die mitgelieferten technischen Schutzmaßnahmen und die Punkte, die ein Betreiber vor einer öffentlichen Bereitstellung selbst prüfen und vervollständigen muss. Sie ersetzt keine Rechtsberatung für den konkreten Einzelfall.
|
||||
|
||||
## 1. Vor dem ersten öffentlichen Start
|
||||
|
||||
1. `.env.example` nach `.env` kopieren und alle `REPLACE_ME`-Werte durch zutreffende Angaben ersetzen.
|
||||
2. `LEGAL_STRICT=true` aktiv lassen. Der Dienst verweigert dann den Start, wenn Kernangaben fehlen.
|
||||
3. Impressum, Datenschutzerklärung und Barrierefreiheitserklärung im fertig deployten System prüfen:
|
||||
- `/impressum`
|
||||
- `/datenschutz`
|
||||
- `/barrierefreiheit`
|
||||
4. Tatsächliche Hosting-, Proxy-, CDN-, DNS-, Logging-, Backup-, Monitoring- und Lizenzdienste mit der Datenschutzerklärung abgleichen.
|
||||
5. Bei Verbraucherverträgen prüfen, welche Angaben nach dem Verbraucherstreitbeilegungsgesetz erforderlich sind. Die frühere EU-OS-Plattform wurde eingestellt; ein alter OS-Link sollte nicht übernommen werden.
|
||||
6. Bei journalistisch-redaktionellen Angeboten prüfen, ob ein Verantwortlicher nach § 18 Abs. 2 MStV benannt werden muss.
|
||||
7. Bei Angeboten an Verbraucher prüfen, ob das BFSG und die BFSGV anwendbar sind. Eine bloße Selbsterklärung ersetzt keinen Accessibility-Audit.
|
||||
|
||||
## 2. Anbieterkennzeichnung
|
||||
|
||||
Die Vorlage deckt typische Felder für § 5 DDG und § 18 MStV ab. Je nach Betreiber können weitere Angaben erforderlich sein, etwa:
|
||||
|
||||
- Rechtsform und Vertretungsberechtigte;
|
||||
- Register, Registernummer und Registergericht;
|
||||
- Umsatzsteuer-Identifikationsnummer;
|
||||
- Aufsichtsbehörde und berufsrechtliche Angaben;
|
||||
- redaktionell Verantwortliche mit Name und Anschrift;
|
||||
- Erklärung zur Verbraucherstreitbeilegung.
|
||||
|
||||
Offizielle Grundlagen:
|
||||
|
||||
- § 5 DDG: https://www.gesetze-im-internet.de/ddg/__5.html
|
||||
- § 18 MStV: https://www.gesetze-bayern.de/Content/Document/MStV-18
|
||||
- § 36 VSBG: https://www.gesetze-im-internet.de/vsbg/__36.html
|
||||
|
||||
## 3. Datenschutz
|
||||
|
||||
Die ausgelieferte Weboberfläche verwendet keine Cookies, kein Tracking und keine Browser-Speicher wie Local Storage. Das allein macht einen Betrieb nicht automatisch datenschutzkonform. Relevant sind insbesondere die tatsächlichen Infrastruktur-Logs und zusätzlich eingebundene Dienste.
|
||||
|
||||
Technischer Standardzustand:
|
||||
|
||||
- kein Datenbank- oder Session-Speicher für Generator-Eingaben;
|
||||
- keine Query-Strings in den Anwendungslogs;
|
||||
- keine Client-IP in den Anwendungslogs, solange `LOG_CLIENT_IP=false` bleibt;
|
||||
- URL-Parameter können dennoch in Browser-Verläufen, Reverse-Proxy-, CDN- oder Hosting-Logs erscheinen;
|
||||
- Hybrid- und Online-Lizenzmodi kommunizieren mit dem konfigurierten Lizenzserver;
|
||||
- öffentliche Erklärungs-URLs dürfen keine vertraulichen oder unnötigen personenbezogenen Daten enthalten.
|
||||
|
||||
Der Betreiber muss insbesondere festlegen und umsetzen:
|
||||
|
||||
- Rechtsgrundlagen und Zwecke;
|
||||
- Empfänger und Auftragsverarbeiter;
|
||||
- Lösch- und Aufbewahrungsfristen;
|
||||
- Drittlandübermittlungen;
|
||||
- technisch-organisatorische Maßnahmen;
|
||||
- Prozesse für Betroffenenrechte und Datenschutzvorfälle.
|
||||
|
||||
Offizielle Grundlagen:
|
||||
|
||||
- Art. 13 DSGVO: https://eur-lex.europa.eu/eli/reg/2016/679/oj
|
||||
- § 25 TDDDG: https://www.gesetze-im-internet.de/ttdsg/__25.html
|
||||
|
||||
## 4. Barrierefreiheit
|
||||
|
||||
Die Anwendung nutzt semantische Formulare und Tabellen, sichtbare Fokuszustände, responsive Layouts und Textalternativen. Sie erhebt ohne gesonderten Audit keinen Anspruch auf vollständige WCAG- oder EN-301-549-Konformität.
|
||||
|
||||
Für ein erfasstes Verbraucherangebot sind unter anderem zu prüfen:
|
||||
|
||||
- Tastaturbedienung und Fokusreihenfolge;
|
||||
- Kontraste, Zoom und Reflow;
|
||||
- verständliche Fehlermeldungen;
|
||||
- Screenreader-Ausgabe;
|
||||
- sichtbarer Text neben eingebetteten Badges; bei dekorativen Badges im empfohlenen Embed `alt=""`, bei alleinstehenden Badge-Grafiken ein aussagekräftiger Alternativtext;
|
||||
- Link-/Klickzielgröße und sichtbare Tastaturfokussierung;
|
||||
- Barrierefreiheit des vollständigen Bestell- oder Vertragspfads;
|
||||
- gesetzlich verlangte Informationen zur Barrierefreiheit.
|
||||
|
||||
Offizielle Informationen: https://www.bundesfachstelle-barrierefreiheit.de/DE/Barrierefreiheitsstaerkungsgesetz
|
||||
|
||||
## 5. Sicherheitsstandard
|
||||
|
||||
Mitgeliefert werden unter anderem:
|
||||
|
||||
- restriktive Content Security Policy mit zufälliger Nonce je HTML-Antwort;
|
||||
- `frame-ancestors 'none'`, `X-Frame-Options: DENY`, `nosniff`, Referrer- und Permissions-Policy;
|
||||
- HSTS bei HTTPS-Basis-URL;
|
||||
- Größenlimit und striktes JSON-Decoding für den Validator;
|
||||
- Begrenzung paralleler Validierungsanfragen;
|
||||
- validierte und begrenzte Request-IDs;
|
||||
- Proxy-Header nur aus ausdrücklich konfigurierten Proxy-Netzen;
|
||||
- standardmäßig deaktivierte Client-IP-Logs und Prometheus-Metriken;
|
||||
- verpflichtender Bearer-Schutz bei aktiviertem `/metrics`;
|
||||
- Non-Root-Container, schreibgeschütztes Dateisystem, entfernte Linux-Capabilities und `no-new-privileges`.
|
||||
|
||||
Zusätzlich in der Betriebsumgebung erforderlich:
|
||||
|
||||
- TLS-Terminierung und sichere Zertifikatsverwaltung;
|
||||
- Rate Limits am Edge/Ingress;
|
||||
- regelmäßige Image- und Abhängigkeitsupdates;
|
||||
- Secret-Management statt Klartext-Umgebungsvariablen, soweit möglich;
|
||||
- Netzwerksegmentierung für Metriken und Lizenzserver;
|
||||
- zentrale Log-Löschung entsprechend `LOG_RETENTION`;
|
||||
- Backup-, Restore- und Incident-Response-Verfahren;
|
||||
- Überwachung ohne unnötige personenbezogene Telemetrie.
|
||||
|
||||
## 6. Reverse Proxy
|
||||
|
||||
`TRUST_PROXY=true` darf nur zusammen mit `TRUSTED_PROXY_CIDRS` verwendet werden. Trage ausschließlich Netze ein, aus denen dein kontrollierter Reverse Proxy die Anwendung tatsächlich erreicht. Andernfalls könnten Clients weitergeleitete IP-Header vortäuschen.
|
||||
|
||||
Beispiel für einen ausschließlich lokalen Proxy:
|
||||
|
||||
```env
|
||||
TRUST_PROXY=true
|
||||
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
|
||||
```
|
||||
|
||||
Docker-, Kubernetes- oder Cloud-Netze unterscheiden sich je Umgebung und dürfen nicht pauschal kopiert werden.
|
||||
|
||||
## 7. Metriken
|
||||
|
||||
`/metrics` ist standardmäßig nicht registriert. Für eine Aktivierung:
|
||||
|
||||
```env
|
||||
METRICS_ENABLED=true
|
||||
METRICS_TOKEN_FILE=/run/secrets/metrics-token
|
||||
```
|
||||
|
||||
Der Bearer-Token schützt den Endpunkt auf Anwendungsebene. Zusätzlich sollte der Endpunkt nicht über den öffentlichen Ingress erreichbar sein.
|
||||
|
||||
## 8. KI-Transparenz und EU AI Act
|
||||
|
||||
Der Standard unterstützt freiwillige Dokumentation und bestimmte Transparenz-Workflows, insbesondere sichtbare Offenlegungen. Er entscheidet nicht automatisch, ob Artikel 50 des AI Act auf einen konkreten Inhalt anwendbar ist. Die offiziellen Transparenzleitlinien der Europäischen Kommission wurden am 20. Juli 2026 veröffentlicht; Artikel 50 gilt ab dem 2. August 2026.
|
||||
|
||||
Wichtige Grenzen:
|
||||
|
||||
- Presets unterstellen keine menschliche oder redaktionelle Prüfung mehr. Ein solcher Prozess muss ausdrücklich angegeben werden.
|
||||
- Eine bloß formale Prüfung wie Rechtschreibung oder Grammatik ist nach den Kommissionsleitlinien keine substantielle menschliche Prüfung oder redaktionelle Kontrolle im Sinne der Ausnahme für bestimmte Texte nach Artikel 50 Absatz 4.
|
||||
- Ein sichtbarer Hinweis muss bei einer einschlägigen Pflicht klar und unterscheidbar spätestens bei der ersten Exposition erscheinen.
|
||||
- Die Emoji-Variante ist nur ein kompakter Zusatz und sollte bei einer gesetzlichen Kennzeichnung nicht ohne verständlichen Text eingesetzt werden.
|
||||
- Das erzeugte JSON-LD ist ergänzende Dokumentation und kein automatischer Ersatz für die Provider-Markierung nach Artikel 50 Absatz 2.
|
||||
- Der AI Act enthält weitere Pflichten außerhalb dieses Projekts, unter anderem AI Literacy nach Artikel 4 sowie gegebenenfalls Verbote, Hochrisiko- oder GPAI-Pflichten.
|
||||
|
||||
Siehe die ausführliche Checkliste in [`EU-AI-ACT-COMPLIANCE.md`](EU-AI-ACT-COMPLIANCE.md).
|
||||
|
||||
- AI Act: https://eur-lex.europa.eu/eli/reg/2024/1689/oj
|
||||
- Leitlinien: https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems
|
||||
- FAQ: https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act
|
||||
- Code of Practice: https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content
|
||||
@@ -16,11 +16,7 @@ Initialization is performed in `internal/app/server.go`. The product ID and embe
|
||||
```go
|
||||
licenses := licenseclient.New(ctx, licenseclient.Config{
|
||||
Product: "ai-disclosure-standard",
|
||||
<<<<<<< HEAD
|
||||
ClientVersion: "2.0.0",
|
||||
=======
|
||||
ClientVersion: "1.8.2",
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
Token: cfg.LicenseToken,
|
||||
TrustStore: trustStore,
|
||||
BaseURL: cfg.BaseURL,
|
||||
|
||||
@@ -119,11 +119,7 @@ Anfrage:
|
||||
"baseUrl": "https://ai.example.org",
|
||||
"host": "ai.example.org",
|
||||
"instanceId": "production-eu-1",
|
||||
<<<<<<< HEAD
|
||||
"clientVersion": "2.0.0"
|
||||
=======
|
||||
"clientVersion": "1.8.2"
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
<<<<<<< HEAD
|
||||
ProductID = "ai-disclosure-standard"
|
||||
ProductVersion = "2.0.0"
|
||||
FeatureCustomText = "custom_text"
|
||||
@@ -16,13 +15,6 @@ const (
|
||||
FeatureWhiteLabel = "white_label"
|
||||
FeatureBulkAPI = "bulk_api"
|
||||
FeatureExportBundle = "export_bundle"
|
||||
=======
|
||||
ProductID = "ai-disclosure-standard"
|
||||
ProductVersion = "1.8.2"
|
||||
FeatureCustomText = "custom_text"
|
||||
FeatureCustomBadge = "custom_badge"
|
||||
FeatureWhiteLabel = "white_label"
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
)
|
||||
|
||||
//go:embed trusted_keys.json
|
||||
|
||||
@@ -430,110 +430,6 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(d)
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
type renderedArtifacts struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
DeclarationURL string `json:"declarationUrl"`
|
||||
BadgeURL string `json:"badgeUrl"`
|
||||
ManifestURL string `json:"manifestUrl"`
|
||||
AccessibleText string `json:"accessibleText"`
|
||||
HTML string `json:"html"`
|
||||
EmbedHTML string `json:"embedHtml"`
|
||||
Markdown string `json:"markdown"`
|
||||
JSONLD declaration.Declaration `json:"jsonLd"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) {
|
||||
q := cloneValues(r.URL.Query())
|
||||
theme := strings.TrimSpace(q.Get("theme"))
|
||||
q.Del("theme")
|
||||
if theme == "" {
|
||||
theme = "mono"
|
||||
}
|
||||
if theme != "mono" && theme != "color" && theme != "emoji" {
|
||||
s.problem(w, http.StatusBadRequest, "invalid_theme", "theme must be one of mono, color or emoji")
|
||||
return
|
||||
}
|
||||
q.Set("lang", s.languageFromValues(r, q))
|
||||
d, err := s.declarationFromQuery(q)
|
||||
if err != nil {
|
||||
s.declarationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
locale := i18n.Get(d.Language)
|
||||
declarationURL := s.cfg.BaseURL + "/declaration?" + q.Encode()
|
||||
manifestURL := s.cfg.BaseURL + "/v1/declaration.json?" + q.Encode()
|
||||
badgeQ := cloneValues(q)
|
||||
badgeQ.Set("theme", theme)
|
||||
badgeQ.Set("link", declarationURL)
|
||||
badgeURL := s.cfg.BaseURL + "/v1/badge.svg?" + badgeQ.Encode()
|
||||
|
||||
assessment := assessLegalContext(d, locale)
|
||||
badgeText := renderBadgeAlt(q, d, locale, assessment)
|
||||
detailsText := strings.TrimSpace(locale.Text["embed_details"])
|
||||
if detailsText == "" {
|
||||
detailsText = "AI usage details"
|
||||
}
|
||||
accessibleText := badgeText + " – " + detailsText
|
||||
badgeHeight := 20
|
||||
if theme == "emoji" {
|
||||
badgeHeight = 48
|
||||
}
|
||||
htmlMarkup := fmt.Sprintf(`<p class="ai-disclosure" data-ai-disclosure><a class="ai-disclosure__link" href="%s" style="display:inline-flex;align-items:center;gap:.5rem;min-height:36px;padding:.25rem .375rem;max-width:100%%;color:inherit;text-decoration:underline;text-underline-offset:.15em"><img class="ai-disclosure__badge" src="%s" alt="" aria-hidden="true" style="height:%dpx;width:auto;max-width:100%%;flex:0 0 auto"><span class="ai-disclosure__text" style="line-height:1.35">%s</span></a></p>`,
|
||||
html.EscapeString(declarationURL), html.EscapeString(badgeURL), badgeHeight, html.EscapeString(accessibleText))
|
||||
markdown := fmt.Sprintf(`[ %s](%s)`, badgeURL, markdownText(accessibleText), declarationURL)
|
||||
jsonLDBytes, err := json.MarshalIndent(d, "", " ")
|
||||
if err != nil {
|
||||
s.logger.Error("could not marshal render JSON-LD", "error", err)
|
||||
s.problem(w, http.StatusInternalServerError, "render_failed", "Could not render JSON-LD.")
|
||||
return
|
||||
}
|
||||
embedMarkup := htmlMarkup + "\n" + `<script type="application/ld+json">` + "\n" + string(jsonLDBytes) + "\n</script>"
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(renderedArtifacts{
|
||||
Subject: d.Subject, DeclarationURL: declarationURL, BadgeURL: badgeURL, ManifestURL: manifestURL, AccessibleText: accessibleText,
|
||||
HTML: htmlMarkup, EmbedHTML: embedMarkup, Markdown: markdown, JSONLD: d,
|
||||
})
|
||||
}
|
||||
|
||||
func renderBadgeAlt(q url.Values, d declaration.Declaration, locale i18n.Locale, assessment legalAssessment) string {
|
||||
if assessment.RequiresDisclosure && assessment.BadgeMessage != "" {
|
||||
return assessment.BadgeMessage
|
||||
}
|
||||
if q.Get("mode") == "article" || len(d.Components) > 1 {
|
||||
if value := locale.Text["badge_article"]; value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if preset, ok := locale.Presets[q.Get("preset")]; ok && preset.Title != "" {
|
||||
return preset.Title
|
||||
}
|
||||
for _, component := range d.Components {
|
||||
if value := locale.Extents[component.AIExtent]; value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return "AI usage disclosure"
|
||||
}
|
||||
|
||||
func markdownAlt(value string) string {
|
||||
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||
value = strings.ReplaceAll(value, `]`, `\]`)
|
||||
return strings.ReplaceAll(value, `[`, `\[`)
|
||||
}
|
||||
|
||||
func markdownText(value string) string {
|
||||
for _, marker := range []string{"\\", "*", "_", "`", "[", "]"} {
|
||||
value = strings.ReplaceAll(value, marker, "\\"+marker)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
func (s *Server) declarationFromQuery(q url.Values) (declaration.Declaration, error) {
|
||||
return declaration.NewFromQueryWithOptions(q, s.cfg.BaseURL+"/context/v1", declaration.ParseOptions{
|
||||
AllowCustomText: s.licenses.Has(FeatureCustomText), AllowCustomBadge: s.licenses.Has(FeatureCustomBadge), DefaultLanguage: s.cfg.DefaultLanguage,
|
||||
|
||||
@@ -395,74 +395,7 @@ func TestRequireLicenseFailsClosedForApplicationRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
r := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
=======
|
||||
func TestUnclearDeepfakeAssessmentUsesCautiousDisclosureGuidance(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/declaration?mode=article&imageExtent=full&imageReview=none&deepfakeAssessment=unsure&legalRole=deployer&useContext=professional&lawEnforcementAuthorization=no&lang=de", nil)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, expected := range []string{"Deepfake-Einordnung", "Unklar / muss geprüft werden", "kann anhand der Angaben nicht ausgeschlossen werden"} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("unclear deepfake guidance missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEndpointReturnsReusableArtifacts(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/render?component=text&extent=partial&review=editorial&lang=de&subject=https%3A%2F%2Fcontent.example%2Farticle&theme=mono", nil)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Subject string `json:"subject"`
|
||||
DeclarationURL string `json:"declarationUrl"`
|
||||
BadgeURL string `json:"badgeUrl"`
|
||||
ManifestURL string `json:"manifestUrl"`
|
||||
AccessibleText string `json:"accessibleText"`
|
||||
HTML string `json:"html"`
|
||||
EmbedHTML string `json:"embedHtml"`
|
||||
Markdown string `json:"markdown"`
|
||||
JSONLD map[string]any `json:"jsonLd"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Subject != "https://content.example/article" {
|
||||
t.Fatalf("subject %q", payload.Subject)
|
||||
}
|
||||
for name, value := range map[string]string{"declaration": payload.DeclarationURL, "badge": payload.BadgeURL, "manifest": payload.ManifestURL, "accessibleText": payload.AccessibleText, "html": payload.HTML, "embedHtml": payload.EmbedHTML, "markdown": payload.Markdown} {
|
||||
if value == "" {
|
||||
t.Fatalf("%s is empty", name)
|
||||
}
|
||||
}
|
||||
if got, _ := payload.JSONLD["subject"].(string); got != payload.Subject {
|
||||
t.Fatalf("jsonLd subject %q", got)
|
||||
}
|
||||
if strings.Contains(payload.DeclarationURL, "theme=") {
|
||||
t.Fatalf("theme leaked into declaration URL: %s", payload.DeclarationURL)
|
||||
}
|
||||
if !strings.Contains(payload.BadgeURL, "theme=mono") {
|
||||
t.Fatalf("badge URL missing theme: %s", payload.BadgeURL)
|
||||
}
|
||||
if !strings.Contains(payload.HTML, `alt="" aria-hidden="true"`) || !strings.Contains(payload.HTML, "Details zur KI-Nutzung") {
|
||||
t.Fatalf("accessible visible disclosure missing: %s", payload.HTML)
|
||||
}
|
||||
if !strings.Contains(payload.EmbedHTML, `<script type="application/ld+json">`) || !strings.Contains(payload.EmbedHTML, `https://content.example/article`) {
|
||||
t.Fatalf("JSON-LD embed missing: %s", payload.EmbedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratorExposesBulkHandoffWhenConfigured(t *testing.T) {
|
||||
h := testHandlerConfig(t, Config{ListenAddress: ":0", BaseURL: "https://example.org", BulkURL: "https://bulk.example.org", PublicName: "Test", DefaultLanguage: "de"})
|
||||
r := httptest.NewRequest(http.MethodGet, "/?lang=de", nil)
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -61,8 +59,6 @@ func Render(o Options) ([]byte, string) {
|
||||
if isHexColor(o.RightColor) {
|
||||
rightColor = o.RightColor
|
||||
}
|
||||
leftText := bestTextColor(leftColor)
|
||||
rightText := bestTextColor(rightColor)
|
||||
|
||||
title := html.EscapeString(label + ": " + message)
|
||||
labelEsc := html.EscapeString(label)
|
||||
@@ -74,111 +70,10 @@ func Render(o Options) ([]byte, string) {
|
||||
closeLink = `</a>`
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="%s" width="%d" height="20" viewBox="0 0 %d 20"><title>%s</title>%s<linearGradient id="s" x2="0" y2="100%%"><stop offset="0" stop-color="#fff" stop-opacity=".12"/><stop offset="1" stop-opacity=".12"/></linearGradient><clipPath id="r"><rect width="%d" height="20" rx="%d" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="%d" height="20" fill="%s"/><rect x="%d" width="%d" height="20" fill="%s"/><rect width="%d" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"><text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text><text x="%d" y="14">%s</text><text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text><text x="%d" y="14">%s</text></g>%s</svg>`,
|
||||
title, total, total, title, openLink, total, radius, leftWidth, leftColor, leftWidth, rightWidth, rightColor, total,
|
||||
leftWidth/2, labelEsc, leftWidth/2, labelEsc, leftWidth+rightWidth/2, messageEsc, leftWidth+rightWidth/2, messageEsc, closeLink)
|
||||
data := []byte(svg)
|
||||
=======
|
||||
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="%s" focusable="false" width="%d" height="20" viewBox="0 0 %d 20"><title>%s</title>%s<clipPath id="r"><rect width="%d" height="20" rx="%d" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="%d" height="20" fill="%s"/><rect x="%d" width="%d" height="20" fill="%s"/></g><g text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11" font-weight="700"><text x="%d" y="14" fill="%s">%s</text><text x="%d" y="14" fill="%s">%s</text></g>%s</svg>`,
|
||||
title, total, total, title, openLink, total, radius, leftWidth, leftColor, leftWidth, rightWidth, rightColor,
|
||||
leftWidth/2, leftText, labelEsc, leftWidth+rightWidth/2, rightText, messageEsc, closeLink)
|
||||
return withETag([]byte(svg))
|
||||
}
|
||||
|
||||
func renderEmoji(o Options, label, message string) ([]byte, string) {
|
||||
iconName := emojiIconName(o)
|
||||
background := emojiPresetColors[o.Preset]
|
||||
if o.Article {
|
||||
background = "#7c3aed"
|
||||
}
|
||||
if background == "" {
|
||||
background = extentColors[o.Extent]
|
||||
}
|
||||
if background == "" {
|
||||
background = "#4a5568"
|
||||
}
|
||||
foreground := "#ffffff"
|
||||
if isHexColor(o.RightColor) {
|
||||
background = o.RightColor
|
||||
}
|
||||
if isHexColor(o.LeftColor) {
|
||||
foreground = o.LeftColor
|
||||
}
|
||||
// Emoji badges use the left colour as icon foreground and the right colour
|
||||
// as background. Keep at least WCAG 2.2's 3:1 non-text contrast; if a
|
||||
// custom pair falls below it, prefer the higher-contrast black/white icon.
|
||||
if contrastRatio(foreground, background) < 3.0 {
|
||||
foreground = bestTextColor(background)
|
||||
}
|
||||
|
||||
title := html.EscapeString(label + ": " + message)
|
||||
openLink, closeLink := svgLink(o.Link)
|
||||
icon := emojiIcon(iconName, foreground)
|
||||
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="%s" focusable="false" width="64" height="64" viewBox="0 0 64 64" data-theme="emoji" data-icon="%s"><title>%s</title>%s<rect width="64" height="64" rx="14" fill="%s"/><path d="M3 18C14 5 35 1 57 5c3 .5 5 3 5 6v7C40 13 20 14 3 27Z" fill="#fff" opacity=".11"/><rect x=".75" y=".75" width="62.5" height="62.5" rx="13.25" fill="none" stroke="#000" stroke-opacity=".14" stroke-width="1.5"/>%s%s</svg>`,
|
||||
title, iconName, title, openLink, background, icon, closeLink)
|
||||
return withETag([]byte(svg))
|
||||
}
|
||||
|
||||
func emojiIconName(o Options) string {
|
||||
if o.Article {
|
||||
return "article"
|
||||
}
|
||||
switch o.Preset {
|
||||
case "no-ai":
|
||||
return "human"
|
||||
case "research":
|
||||
return "research"
|
||||
case "summary":
|
||||
return "summary"
|
||||
case "full":
|
||||
return "bolt"
|
||||
}
|
||||
switch o.Extent {
|
||||
case "none":
|
||||
return "human"
|
||||
case "partial":
|
||||
return "partial"
|
||||
case "mostly":
|
||||
return "mostly"
|
||||
case "full":
|
||||
return "bolt"
|
||||
default:
|
||||
return "assisted"
|
||||
}
|
||||
}
|
||||
|
||||
func emojiIcon(name, color string) string {
|
||||
stroke := ` fill="none" stroke="` + color + `" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"`
|
||||
switch name {
|
||||
case "human":
|
||||
return `<circle cx="32" cy="21" r="8"` + stroke + `/><path d="M17 50c1.8-10 7.5-15 15-15s13.2 5 15 15"` + stroke + `/>`
|
||||
case "research":
|
||||
return `<circle cx="27" cy="27" r="12"` + stroke + `/><path d="m36 36 13 13"` + stroke + `/><path d="M27 21v12M21 27h12"` + stroke + `/>`
|
||||
case "summary":
|
||||
return `<rect x="17" y="12" width="30" height="40" rx="5"` + stroke + `/><path d="M24 24h16M24 32h16M24 40h11"` + stroke + `/>`
|
||||
case "bolt":
|
||||
return `<path d="M36 8 18 35h12l-2 21 18-29H34Z" fill="` + color + `"/>`
|
||||
case "article":
|
||||
return `<rect x="14" y="14" width="15" height="15" rx="4"` + stroke + `/><rect x="35" y="14" width="15" height="15" rx="4"` + stroke + `/><rect x="14" y="35" width="15" height="15" rx="4"` + stroke + `/><rect x="35" y="35" width="15" height="15" rx="4"` + stroke + `/>`
|
||||
case "partial":
|
||||
return `<circle cx="32" cy="32" r="18"` + stroke + `/><path d="M32 14a18 18 0 0 0 0 36Z" fill="` + color + `"/>`
|
||||
case "mostly":
|
||||
return `<path d="M18 45V35M32 45V27M46 45V18"` + stroke + `/><path d="M14 50h36"` + stroke + `/>`
|
||||
default:
|
||||
return `<path d="M32 12c1.5 8.2 5.8 12.5 14 14-8.2 1.5-12.5 5.8-14 14-1.5-8.2-5.8-12.5-14-14 8.2-1.5 12.5-5.8 14-14Z"` + stroke + `/><path d="M46 39c.7 3.8 2.7 5.8 6.5 6.5C48.7 46.2 46.7 48.2 46 52c-.7-3.8-2.7-5.8-6.5-6.5 3.8-.7 5.8-2.7 6.5-6.5Z" fill="` + color + `"/>`
|
||||
}
|
||||
}
|
||||
|
||||
func svgLink(link string) (string, string) {
|
||||
if strings.HasPrefix(link, "https://") || strings.HasPrefix(link, "http://") {
|
||||
return `<a href="` + html.EscapeString(link) + `" target="_top">`, `</a>`
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func withETag(data []byte) ([]byte, string) {
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
hash := sha256.Sum256(data)
|
||||
return data, `"` + hex.EncodeToString(hash[:12]) + `"`
|
||||
}
|
||||
@@ -200,45 +95,6 @@ func truncate(s string, max int) string {
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
|
||||
func bestTextColor(background string) string {
|
||||
if contrastRatio("#000000", background) >= contrastRatio("#ffffff", background) {
|
||||
return "#000000"
|
||||
}
|
||||
return "#ffffff"
|
||||
}
|
||||
|
||||
func contrastRatio(foreground, background string) float64 {
|
||||
fg, okFG := relativeLuminance(foreground)
|
||||
bg, okBG := relativeLuminance(background)
|
||||
if !okFG || !okBG {
|
||||
return 1
|
||||
}
|
||||
if fg < bg {
|
||||
fg, bg = bg, fg
|
||||
}
|
||||
return (fg + 0.05) / (bg + 0.05)
|
||||
}
|
||||
|
||||
func relativeLuminance(hex string) (float64, bool) {
|
||||
if !isHexColor(hex) {
|
||||
return 0, false
|
||||
}
|
||||
channels := make([]float64, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
v, err := strconv.ParseUint(hex[1+i*2:3+i*2], 16, 8)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
c := float64(v) / 255.0
|
||||
if c <= 0.04045 {
|
||||
channels[i] = c / 12.92
|
||||
} else {
|
||||
channels[i] = math.Pow((c+0.055)/1.055, 2.4)
|
||||
}
|
||||
}
|
||||
return 0.2126*channels[0] + 0.7152*channels[1] + 0.0722*channels[2], true
|
||||
}
|
||||
|
||||
func isHexColor(s string) bool {
|
||||
if len(s) != 7 || s[0] != '#' {
|
||||
return false
|
||||
|
||||
@@ -17,111 +17,3 @@ func TestRenderEscapesText(t *testing.T) {
|
||||
t.Fatal("missing etag")
|
||||
}
|
||||
}
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
|
||||
func TestRenderEmojiIsSquareAndPresetSpecific(t *testing.T) {
|
||||
data, etag := Render(Options{
|
||||
Label: "KI-Nutzung",
|
||||
Message: "Vollständige Inhaltserstellung",
|
||||
Extent: "full",
|
||||
Preset: "full",
|
||||
Theme: "emoji",
|
||||
})
|
||||
body := string(data)
|
||||
for _, expected := range []string{
|
||||
`width="64" height="64"`,
|
||||
`viewBox="0 0 64 64"`,
|
||||
`data-theme="emoji"`,
|
||||
`data-icon="bolt"`,
|
||||
`fill="#c53030"`,
|
||||
`<title>KI-Nutzung: Vollständige Inhaltserstellung</title>`,
|
||||
} {
|
||||
if !bytes.Contains(data, []byte(expected)) {
|
||||
t.Fatalf("emoji SVG missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
if etag == "" {
|
||||
t.Fatal("missing etag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEmojiUsesCustomColoursAndEscapesAccessibleText(t *testing.T) {
|
||||
data, _ := Render(Options{
|
||||
Label: `<AI>`,
|
||||
Message: `Research & review`,
|
||||
Extent: "assisted",
|
||||
Preset: "research",
|
||||
Theme: "emoji",
|
||||
LeftColor: "#112233",
|
||||
RightColor: "#abcdef",
|
||||
})
|
||||
body := string(data)
|
||||
for _, expected := range []string{
|
||||
`data-icon="research"`,
|
||||
`fill="#abcdef"`,
|
||||
`stroke="#112233"`,
|
||||
`<AI>: Research & review`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("emoji SVG missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `<AI>`) {
|
||||
t.Fatal("accessible text was not escaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiIconMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options Options
|
||||
icon string
|
||||
}{
|
||||
{name: "no AI", options: Options{Extent: "none", Preset: "no-ai", Theme: "emoji"}, icon: "human"},
|
||||
{name: "research", options: Options{Extent: "assisted", Preset: "research", Theme: "emoji"}, icon: "research"},
|
||||
{name: "summary", options: Options{Extent: "assisted", Preset: "summary", Theme: "emoji"}, icon: "summary"},
|
||||
{name: "full", options: Options{Extent: "full", Preset: "full", Theme: "emoji"}, icon: "bolt"},
|
||||
{name: "article", options: Options{Extent: "mostly", Article: true, Theme: "emoji"}, icon: "article"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data, _ := Render(tt.options)
|
||||
if !bytes.Contains(data, []byte(`data-icon="`+tt.icon+`"`)) {
|
||||
t.Fatalf("expected icon %q: %s", tt.icon, data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlatBadgeAutomaticallyChoosesContrastingText(t *testing.T) {
|
||||
data, _ := Render(Options{Label: "KI-Nutzung", Message: "Teilweise", Extent: "partial", Theme: "color"})
|
||||
body := string(data)
|
||||
if !strings.Contains(body, `fill="#b7791f"`) {
|
||||
t.Fatalf("expected partial background: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `fill="#000000">Teilweise</text>`) {
|
||||
t.Fatalf("expected automatic black text on gold background: %s", body)
|
||||
}
|
||||
if ratio := contrastRatio("#000000", "#b7791f"); ratio < 4.5 {
|
||||
t.Fatalf("contrast ratio %.2f is below 4.5", ratio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiBadgeAdjustsInsufficientCustomForegroundContrast(t *testing.T) {
|
||||
data, _ := Render(Options{
|
||||
Label: "KI-Nutzung", Message: "Hinweis", Extent: "assisted", Theme: "emoji",
|
||||
LeftColor: "#ffffff", RightColor: "#abcdef",
|
||||
})
|
||||
body := string(data)
|
||||
if strings.Contains(body, `stroke="#ffffff"`) {
|
||||
t.Fatalf("low-contrast custom foreground was not adjusted: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `stroke="#000000"`) {
|
||||
t.Fatalf("expected automatic black icon foreground: %s", body)
|
||||
}
|
||||
if ratio := contrastRatio("#000000", "#abcdef"); ratio < 3 {
|
||||
t.Fatalf("adjusted contrast ratio %.2f is below 3", ratio)
|
||||
}
|
||||
}
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
package bulk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bulkweb "github.com/b1tsblog/ai-disclosure-standard/bulkweb"
|
||||
)
|
||||
|
||||
const Version = "1.0.2"
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
logger *slog.Logger
|
||||
templates *template.Template
|
||||
client *http.Client
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Name string
|
||||
Version string
|
||||
DisclosureBaseURL string
|
||||
GeneratorURL string
|
||||
MaxURLs int
|
||||
}
|
||||
|
||||
type publicConfig struct {
|
||||
DisclosureBaseURL string `json:"disclosureBaseURL"`
|
||||
GeneratorURL string `json:"generatorURL"`
|
||||
MaxURLs int `json:"maxURLs"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type batchRequest struct {
|
||||
Template string `json:"template"`
|
||||
Subjects []string `json:"subjects"`
|
||||
}
|
||||
|
||||
type batchResult struct {
|
||||
Subject string `json:"subject"`
|
||||
OK bool `json:"ok"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type batchResponse struct {
|
||||
Results []batchResult `json:"results"`
|
||||
}
|
||||
|
||||
func New(cfg Config, logger *slog.Logger) (http.Handler, error) {
|
||||
if err := ValidateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl, err := template.New("root").ParseFS(bulkweb.Files, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse bulk templates: %w", err)
|
||||
}
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
templates: tmpl,
|
||||
client: &http.Client{
|
||||
Timeout: cfg.RequestTimeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
s.routes()
|
||||
return s.securityHeaders(s.mux), nil
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
staticFS, _ := fs.Sub(bulkweb.Files, "static")
|
||||
s.mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
s.mux.HandleFunc("GET /", s.handleIndex)
|
||||
s.mux.HandleFunc("GET /api/config", s.handleConfig)
|
||||
s.mux.HandleFunc("POST /api/render-batch", s.handleRenderBatch)
|
||||
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = s.templates.ExecuteTemplate(w, "index.html", pageData{
|
||||
Name: s.cfg.PublicName, Version: Version, DisclosureBaseURL: s.cfg.DisclosureBaseURL,
|
||||
GeneratorURL: s.cfg.GeneratorURL, MaxURLs: s.cfg.MaxURLs,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(publicConfig{
|
||||
DisclosureBaseURL: s.cfg.DisclosureBaseURL,
|
||||
GeneratorURL: s.cfg.GeneratorURL,
|
||||
MaxURLs: s.cfg.MaxURLs,
|
||||
Version: Version,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRenderBatch(w http.ResponseWriter, r *http.Request) {
|
||||
body := http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
defer body.Close()
|
||||
dec := json.NewDecoder(body)
|
||||
dec.DisallowUnknownFields()
|
||||
var request batchRequest
|
||||
if err := dec.Decode(&request); err != nil {
|
||||
s.problem(w, http.StatusBadRequest, "invalid_json", "Request body must be valid JSON.")
|
||||
return
|
||||
}
|
||||
if err := ensureEOF(dec); err != nil {
|
||||
s.problem(w, http.StatusBadRequest, "invalid_json", "Request body must contain exactly one JSON object.")
|
||||
return
|
||||
}
|
||||
if len(request.Subjects) == 0 {
|
||||
s.problem(w, http.StatusBadRequest, "missing_subjects", "At least one subject URL is required.")
|
||||
return
|
||||
}
|
||||
if len(request.Subjects) > s.cfg.MaxURLs {
|
||||
s.problem(w, http.StatusRequestEntityTooLarge, "too_many_subjects", fmt.Sprintf("At most %d subject URLs are allowed per batch.", s.cfg.MaxURLs))
|
||||
return
|
||||
}
|
||||
if len(request.Template) > 24<<10 {
|
||||
s.problem(w, http.StatusRequestEntityTooLarge, "template_too_large", "Template query is too large.")
|
||||
return
|
||||
}
|
||||
|
||||
templateValues, err := url.ParseQuery(strings.TrimPrefix(strings.TrimSpace(request.Template), "?"))
|
||||
if err != nil {
|
||||
s.problem(w, http.StatusBadRequest, "invalid_template", "Template must be a valid URL query string.")
|
||||
return
|
||||
}
|
||||
templateValues.Del("subject")
|
||||
|
||||
results := make([]batchResult, len(request.Subjects))
|
||||
jobs := make(chan int)
|
||||
var wg sync.WaitGroup
|
||||
workers := min(s.cfg.Workers, len(request.Subjects))
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for idx := range jobs {
|
||||
subject := strings.TrimSpace(request.Subjects[idx])
|
||||
results[idx] = s.renderOne(r.Context(), templateValues, subject)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for idx := range request.Subjects {
|
||||
jobs <- idx
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(batchResponse{Results: results})
|
||||
}
|
||||
|
||||
func (s *Server) renderOne(ctx context.Context, templateValues url.Values, subject string) batchResult {
|
||||
if err := validateSubject(subject); err != nil {
|
||||
return batchResult{Subject: subject, Error: err.Error()}
|
||||
}
|
||||
values := cloneValues(templateValues)
|
||||
values.Set("subject", subject)
|
||||
endpoint := s.cfg.CoreInternalURL + "/v1/render?" + values.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return batchResult{Subject: subject, Error: "could not build core request"}
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
s.logger.Warn("core render failed", "error", err)
|
||||
return batchResult{Subject: subject, Error: "disclosure core is unavailable"}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return batchResult{Subject: subject, Error: "could not read disclosure core response"}
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
message := coreErrorMessage(payload)
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("disclosure core returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return batchResult{Subject: subject, Error: message}
|
||||
}
|
||||
if !json.Valid(payload) {
|
||||
return batchResult{Subject: subject, Error: "disclosure core returned invalid JSON"}
|
||||
}
|
||||
return batchResult{Subject: subject, OK: true, Data: json.RawMessage(payload)}
|
||||
}
|
||||
|
||||
func validateSubject(raw string) error {
|
||||
u, err := url.ParseRequestURI(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
|
||||
return errors.New("subject must be an absolute http(s) URL without credentials")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func coreErrorMessage(payload []byte) string {
|
||||
var problem struct {
|
||||
Detail string `json:"detail"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if json.Unmarshal(payload, &problem) != nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(problem.Detail) != "" {
|
||||
return problem.Detail
|
||||
}
|
||||
return strings.TrimSpace(problem.Title)
|
||||
}
|
||||
|
||||
func ensureEOF(dec *json.Decoder) error {
|
||||
var extra any
|
||||
err := dec.Decode(&extra)
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err == nil {
|
||||
return errors.New("unexpected second JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func cloneValues(in url.Values) url.Values {
|
||||
out := make(url.Values, len(in))
|
||||
for key, values := range in {
|
||||
out[key] = append([]string(nil), values...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) problem(w http.ResponseWriter, status int, code, detail string) {
|
||||
w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"type": "about:blank", "title": code, "status": status, "detail": detail,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
|
||||
started := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
s.logger.Debug("request", "method", r.Method, "path", r.URL.Path, "duration", time.Since(started))
|
||||
})
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package bulk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func TestRenderBatchUsesFixedCoreAndReplacesSubject(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var subjects []string
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/render" {
|
||||
t.Fatalf("unexpected core path %q", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("extent"); got != "partial" {
|
||||
t.Fatalf("template extent = %q", got)
|
||||
}
|
||||
subject := r.URL.Query().Get("subject")
|
||||
mu.Lock()
|
||||
subjects = append(subjects, subject)
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"subject": subject,
|
||||
"declarationUrl": "https://public.example/declaration",
|
||||
"badgeUrl": "https://public.example/badge.svg",
|
||||
"manifestUrl": "https://public.example/manifest.json",
|
||||
"html": "<a>ok</a>",
|
||||
"markdown": "[ok]",
|
||||
"jsonLd": map[string]any{"@type": "AIUsageDeclaration", "subject": subject},
|
||||
})
|
||||
}))
|
||||
defer core.Close()
|
||||
|
||||
h, err := New(Config{
|
||||
ListenAddress: ":0", CoreInternalURL: core.URL,
|
||||
DisclosureBaseURL: "https://public.example", GeneratorURL: "https://public.example",
|
||||
PublicName: "Bulk", MaxURLs: 10, Workers: 2, RequestTimeout: 2 * time.Second,
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := `{"template":"extent=partial&subject=https%3A%2F%2Fold.example%2Fignored","subjects":["https://content.example/a","https://content.example/b"]}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/render-batch", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var response batchResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Results) != 2 || !response.Results[0].OK || !response.Results[1].OK {
|
||||
t.Fatalf("unexpected results: %#v", response.Results)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(subjects) != 2 {
|
||||
t.Fatalf("core calls = %d", len(subjects))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, subject := range subjects {
|
||||
seen[subject] = true
|
||||
}
|
||||
if !seen["https://content.example/a"] || !seen["https://content.example/b"] || seen["https://old.example/ignored"] {
|
||||
t.Fatalf("subjects sent to core: %#v", subjects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBatchRejectsNonHTTPSubjectWithoutCallingCore(t *testing.T) {
|
||||
calls := 0
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer core.Close()
|
||||
|
||||
h, err := New(Config{ListenAddress: ":0", CoreInternalURL: core.URL, DisclosureBaseURL: "https://public.example", GeneratorURL: "https://public.example", PublicName: "Bulk", MaxURLs: 10, Workers: 1, RequestTimeout: 2 * time.Second}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/render-batch", strings.NewReader(`{"template":"extent=partial","subjects":["file:///etc/passwd"]}`))
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var response batchResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Results) != 1 || response.Results[0].OK || !strings.Contains(response.Results[0].Error, "http(s)") {
|
||||
t.Fatalf("unexpected response: %#v", response.Results)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("core was called %d times for invalid subject", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBatchHonoursConfiguredLimit(t *testing.T) {
|
||||
h, err := New(Config{ListenAddress: ":0", CoreInternalURL: "http://127.0.0.1:9", DisclosureBaseURL: "https://public.example", GeneratorURL: "https://public.example", PublicName: "Bulk", MaxURLs: 1, Workers: 1, RequestTimeout: time.Second}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/render-batch", strings.NewReader(`{"template":"extent=partial","subjects":["https://example.org/a","https://example.org/b"]}`))
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkUIUsesAccessibleDisclosurePreview(t *testing.T) {
|
||||
h, err := New(Config{
|
||||
ListenAddress: ":0", CoreInternalURL: "http://127.0.0.1:9",
|
||||
DisclosureBaseURL: "https://public.example", GeneratorURL: "https://public.example",
|
||||
PublicName: "Bulk", MaxURLs: 10, Workers: 1, RequestTimeout: time.Second,
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
indexReq := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
indexRes := httptest.NewRecorder()
|
||||
h.ServeHTTP(indexRes, indexReq)
|
||||
if indexRes.Code != http.StatusOK {
|
||||
t.Fatalf("index status %d: %s", indexRes.Code, indexRes.Body.String())
|
||||
}
|
||||
if !strings.Contains(indexRes.Body.String(), "<th>Kennzeichnung</th>") {
|
||||
t.Fatalf("bulk result table does not expose the disclosure preview column")
|
||||
}
|
||||
|
||||
jsReq := httptest.NewRequest(http.MethodGet, "/static/bulk.js", nil)
|
||||
jsRes := httptest.NewRecorder()
|
||||
h.ServeHTTP(jsRes, jsReq)
|
||||
if jsRes.Code != http.StatusOK {
|
||||
t.Fatalf("bulk.js status %d: %s", jsRes.Code, jsRes.Body.String())
|
||||
}
|
||||
body := jsRes.Body.String()
|
||||
for _, required := range []string{"buildDisclosurePreview", "accessibleText", "SVG (nur Grafik)"} {
|
||||
if !strings.Contains(body, required) {
|
||||
t.Fatalf("bulk.js missing accessible preview marker %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,292 +135,7 @@ func init() {
|
||||
applyEuropeanTaxonomyTranslations()
|
||||
applyArticleTranslations()
|
||||
applyAssuranceTranslations()
|
||||
<<<<<<< HEAD
|
||||
applyArticle50Translations()
|
||||
=======
|
||||
applyThemeTranslations()
|
||||
applyLegalGeneratorTranslations()
|
||||
applyEmbedTranslations()
|
||||
}
|
||||
|
||||
func applyEmbedTranslations() {
|
||||
translations := map[string]map[string]string{
|
||||
"de": {
|
||||
"embed_details": "Details zur KI-Nutzung",
|
||||
"embed_html_label": "Komplettes HTML inkl. JSON-LD",
|
||||
"copy_html": "Komplettes HTML kopieren",
|
||||
"embed_markdown_label": "Barrierearmes Markdown",
|
||||
"embed_json_label": "JSON-LD",
|
||||
"embed_rendering": "Einbettung wird erzeugt …",
|
||||
"embed_render_error": "Einbettung konnte nicht erzeugt werden.",
|
||||
"contrast_adjusted": "Der Kontrast der gewählten Emoji-Farben ist zu gering; das Symbol wird automatisch mit Schwarz oder Weiß dargestellt.",
|
||||
},
|
||||
"en": {
|
||||
"embed_details": "AI usage details",
|
||||
"embed_html_label": "Complete HTML incl. JSON-LD",
|
||||
"copy_html": "Copy complete HTML",
|
||||
"embed_markdown_label": "Accessible Markdown",
|
||||
"embed_json_label": "JSON-LD",
|
||||
"embed_rendering": "Rendering embed …",
|
||||
"embed_render_error": "The embed could not be rendered.",
|
||||
"contrast_adjusted": "The selected emoji colours have insufficient contrast; the icon will automatically use black or white.",
|
||||
},
|
||||
"fr": {"embed_details": "Détails sur l’utilisation de l’IA", "embed_html_label": "HTML complet avec JSON-LD", "embed_markdown_label": "Markdown accessible", "embed_json_label": "JSON-LD"},
|
||||
"es": {"embed_details": "Detalles sobre el uso de IA", "embed_html_label": "HTML completo con JSON-LD", "embed_markdown_label": "Markdown accesible", "embed_json_label": "JSON-LD"},
|
||||
"it": {"embed_details": "Dettagli sull’uso dell’IA", "embed_html_label": "HTML completo con JSON-LD", "embed_markdown_label": "Markdown accessibile", "embed_json_label": "JSON-LD"},
|
||||
"nl": {"embed_details": "Details over AI-gebruik", "embed_html_label": "Volledige HTML met JSON-LD", "embed_markdown_label": "Toegankelijke Markdown", "embed_json_label": "JSON-LD"},
|
||||
"pt": {"embed_details": "Detalhes sobre a utilização de IA", "embed_html_label": "HTML completo com JSON-LD", "embed_markdown_label": "Markdown acessível", "embed_json_label": "JSON-LD"},
|
||||
"pl": {"embed_details": "Szczegóły użycia AI", "embed_html_label": "Pełny HTML z JSON-LD", "embed_markdown_label": "Dostępny Markdown", "embed_json_label": "JSON-LD"},
|
||||
}
|
||||
for code, values := range translations {
|
||||
l := catalogs[code]
|
||||
for key, value := range values {
|
||||
l.Text[key] = value
|
||||
}
|
||||
if l.Text["embed_rendering"] == "" {
|
||||
l.Text["embed_rendering"] = translations["en"]["embed_rendering"]
|
||||
}
|
||||
if l.Text["embed_render_error"] == "" {
|
||||
l.Text["embed_render_error"] = translations["en"]["embed_render_error"]
|
||||
}
|
||||
if l.Text["contrast_adjusted"] == "" {
|
||||
l.Text["contrast_adjusted"] = translations["en"]["contrast_adjusted"]
|
||||
}
|
||||
catalogs[code] = l
|
||||
}
|
||||
}
|
||||
|
||||
func applyLegalGeneratorTranslations() {
|
||||
en := map[string]string{
|
||||
"generator_step_content": "1. Content and AI use",
|
||||
"generator_step_legal": "2. Article 50 scope check",
|
||||
"generator_step_accountability": "3. Publication and accountability",
|
||||
"generator_step_output": "4. Output",
|
||||
"field_required": "Required for this assessment",
|
||||
"field_optional": "Optional",
|
||||
"field_best_practice": "Best practice",
|
||||
"assessment_yes": "Yes",
|
||||
"assessment_no": "No",
|
||||
"assessment_unsure": "Unclear / needs assessment",
|
||||
"field_legal_role": "Your role under the AI Act",
|
||||
"legal_role_deployer": "Deployer – I use an AI system under my authority",
|
||||
"legal_role_provider": "Provider – I provide / place the AI system on the market",
|
||||
"legal_role_both": "Both provider and deployer",
|
||||
"legal_role_unsure": "Unclear / needs assessment",
|
||||
"legal_role_help": "Article 50 assigns different duties to providers and deployers. This generator mainly documents publication-side disclosures; provider-side machine marking is separate. Article 50(2) also contains a limited exception for standard editing assistance or outputs that do not substantially alter the deployer's input data or semantics.",
|
||||
"field_use_context": "Use context",
|
||||
"use_context_professional": "Professional / organisational use",
|
||||
"use_context_personalNonProfessional": "Purely personal, non-professional use",
|
||||
"use_context_unsure": "Unclear / mixed use",
|
||||
"use_context_help": "A person using AI only for a personal, non-professional activity is generally not a deployer under the AI Act. Economic, business, occupational or freelance use can be professional.",
|
||||
"field_output_date": "Date the affected AI output was generated or manipulated",
|
||||
"output_date_help": "Article 50 applies from 2 August 2026. The Commission FAQ states that content generated before that date does not have to be labelled retroactively under Article 50.",
|
||||
"legal_scope_intro": "The following questions are shown only where they fit the selected content. They support a cautious self-assessment and do not constitute legal advice.",
|
||||
"legal_context_legend": "Legal context (self-assessment)",
|
||||
"legal_context_help": "These details support a cautious Article 50 assessment and are not an automated legal decision.",
|
||||
"legal_deepfake": "Deepfake / realistic AI manipulation",
|
||||
"legal_deepfake_help": "Select only where image, audio or video resembles existing or plausible persons, objects, places, entities or events and could falsely appear authentic or truthful.",
|
||||
"legal_public_interest": "AI-generated or manipulated text informing the public on a matter of public interest",
|
||||
"legal_public_interest_help": "Examples can include politics, public administration, fundamental rights, safety, health, the environment, consumer safety and significant economic, scientific or cultural developments.",
|
||||
"legal_creative": "Evidently artistic / creative / satirical / fictional",
|
||||
"legal_creative_help": "For deepfakes in such works, disclosure may be made appropriately without hampering display or enjoyment of the work.",
|
||||
"law_enforcement_title": "Legally authorised criminal-law use",
|
||||
"field_law_enforcement_authorization": "Is this use authorised by law to detect, prevent, investigate or prosecute criminal offences?",
|
||||
"law_enforcement_no": "No",
|
||||
"law_enforcement_yes": "Yes – legally authorised",
|
||||
"law_enforcement_unsure": "Unclear / needs legal review",
|
||||
"law_enforcement_help": "Article 50(4) contains a specific exception for qualifying uses authorised by law for criminal-law purposes. Do not select ‘yes’ merely because the publisher is a public authority; an actual legal authorisation for the specific use is required.",
|
||||
"legal_other_voluntary": "Other / voluntary transparency",
|
||||
"legal_assessment_heading": "Cautious Article 50 assessment",
|
||||
"legal_assessment_pending": "The Article 50 assessment is not complete yet. Answer the currently required scope questions; until then, the generator does not state that a disclosure duty exists or does not exist.",
|
||||
"legal_assessment_none": "No content-specific disclosure category under Article 50(4) was identified from the supplied information. This does not determine whether other Article 50 or other legal duties apply; the label is treated here as voluntary transparency.",
|
||||
"legal_assessment_voluntary": "The supplied information currently points to voluntary transparency; this does not mean that other AI Act duties are inapplicable.",
|
||||
"legal_assessment_deepfake": "Based on the self-classification as a deepfake, there are strong indications that a clear and distinguishable disclosure is required no later than first exposure.",
|
||||
"legal_assessment_deepfake_creative": "The content is classified as both a deepfake and evidently artistic, creative, satirical or fictional. Disclosure remains relevant but may be made appropriately without hampering display or enjoyment of the work.",
|
||||
"legal_assessment_public_required": "Based on the supplied information, there are strong indications that the AI-generated or manipulated public-interest text should be clearly disclosed. Substantive human review/editorial control together with expressly assumed editorial responsibility is not fully documented.",
|
||||
"legal_assessment_public_exemption": "The supplied information documents substantive human review/editorial control and expressly named editorial responsibility. The Article 50(4) exception for certain public-interest text may therefore be relevant. Voluntary transparency remains possible.",
|
||||
"legal_assessment_inconsistent": "The selected legal category does not fully match the structured content data. Check the AI contribution and content components.",
|
||||
"legal_assessment_deepfake_unsure": "It is unclear whether the AI-involved media qualifies as a deepfake. A content-specific Article 50(4) duty cannot be ruled out from the supplied facts. Until the classification is resolved, a clear disclosure is the more cautious publication setting.",
|
||||
"legal_assessment_public_unsure": "It is unclear whether the AI-involved text is published to inform the public on a matter of public interest. A content-specific Article 50(4) duty cannot be ruled out from the supplied facts. Until the classification is resolved, a clear disclosure is the more cautious publication setting.",
|
||||
"badge_legal_disclosure": "AI-generated / manipulated content",
|
||||
"badge_public_interest": "AI-generated / edited content",
|
||||
"fact_legal_context": "Legal context (self-assessment)",
|
||||
"field_responsibility_role": "Type of editorial responsibility",
|
||||
"responsibility_none": "Not specified",
|
||||
"responsibility_publisher": "Publishing person/organisation assumes editorial responsibility",
|
||||
"responsibility_other": "Another responsible party",
|
||||
"field_responsible": "Editorially responsible person/organisation",
|
||||
"responsible_placeholder": "Name or organisation",
|
||||
"field_responsible_url": "Evidence / imprint for editorial responsibility",
|
||||
"legal_deepfake_not_relevant": "Deepfake assessment is shown only for AI-involved image, audio or video content.",
|
||||
"legal_public_not_relevant": "Public-interest text assessment is shown only where the text component contains AI-generated or manipulated content.",
|
||||
"editorial_exception_title": "Possible public-interest text exception",
|
||||
"editorial_exception_help": "For the Article 50(4) exception, both human review/editorial control and a natural or legal person holding editorial responsibility for publication must be present. Superficial or purely formal checks are not enough.",
|
||||
"responsibility_required_help": "Shown because you selected public-interest text and documented substantive review. Name the person or legal entity that ultimately holds editorial legal responsibility for publication if you want the exception to be considered.",
|
||||
"field_author": "Author / byline",
|
||||
"field_author_url": "Author profile / reference URL",
|
||||
"author_help": "The author is not automatically the same as the person or organisation holding editorial legal responsibility. This field is publication metadata, not a general Article 50 requirement.",
|
||||
"accountability_intro": "These fields are separated from Article 50 requirements. They improve traceability without implying that every publication is legally required to provide them.",
|
||||
"complaints_title": "Complaint / feedback contact",
|
||||
"complaints_help": "Best practice for transparent publications. Article 50 does not create a general duty to name an internal complaint office for each labelled item. This voluntary contact also does not replace the separate right under Article 85 AI Act to lodge a complaint with a market surveillance authority; sectoral, media, consumer or platform rules may add further obligations.",
|
||||
"field_complaint_name": "Contact point / organisation",
|
||||
"field_complaint_email": "Complaint / feedback email",
|
||||
"field_complaint_url": "Complaint / feedback URL",
|
||||
"validation_heading": "Plausibility and legal-context notes",
|
||||
"validation_formal_review": "Only a basic/formal review is documented. According to the Commission guidance, spelling, grammar or other superficial checks are not sufficient human review/editorial control for the public-interest text exception.",
|
||||
"validation_missing_responsibility": "Substantive review is selected, but editorial responsibility is not fully named. The public-interest text exception should not be assumed on these facts.",
|
||||
"validation_provider": "Provider role selected: a visible badge or JSON-LD declaration does not replace the provider-side machine-readable marking duty for in-scope generative AI outputs.",
|
||||
"validation_role_unsure": "The AI Act actor role is unresolved. Deployer-side disclosure duties cannot safely be ruled out until it is clear whether the system is used under your authority in a professional/organisational context.",
|
||||
"validation_use_context_unsure": "The use context is unresolved or mixed. Treat professional, business, occupational or freelance use separately from purely personal non-professional use before relying on the personal-use exclusion.",
|
||||
"validation_personal": "Purely personal, non-professional use is generally outside the AI Act deployer definition. A voluntary transparency label may still be useful and other law can still apply.",
|
||||
"validation_pre_application": "The entered output date is before 2 August 2026. The Commission FAQ says such content does not need to be labelled retroactively under Article 50; other legal duties remain unaffected.",
|
||||
"validation_public_no_ai": "Public-interest text is selected, but no AI involvement in text is documented. Check the content data or remove this legal category.",
|
||||
"validation_deepfake_no_media": "Deepfake is selected, but no AI-involved image, audio or video component is documented. Check the content data or remove this legal category.",
|
||||
"validation_deepfake_unsure": "Deepfake classification is unresolved. Consider resemblance, whether the simulated subject exists or plausibly existed, false appearance of authenticity/truthfulness, deployment context and audience expectations before relying on a 'no' conclusion.",
|
||||
"validation_public_unsure": "The public-interest classification is unresolved. Check whether the text is published, intended to inform the public, and concerns a matter of public interest before relying on a 'no' conclusion.",
|
||||
"validation_creative_unsure": "The creative/satirical/fictional classification is unresolved. This affects the manner of deepfake disclosure, not whether the deepfake transparency obligation exists in principle.",
|
||||
"validation_complaint_incomplete": "Complete the complaint / feedback contact with a name and at least one valid email address or URL.",
|
||||
"consequence_disclosure": "Likely consequence: use a clear, distinguishable disclosure no later than first exposure to the affected content; accessibility requirements also apply.",
|
||||
"consequence_provider": "Provider-side consequence: separately assess Article 50(2) machine-readable marking, including whether the limited standard-editing / non-substantial-alteration exception actually applies. This generator is supplementary documentation only.",
|
||||
"consequence_voluntary": "No content-specific Article 50(4) duty is established by the supplied answers. Voluntary transparency remains possible and other AI Act or sector-specific duties may still apply.",
|
||||
"legal_assessment_personal": "The supplied use context is purely personal and non-professional. Such use is generally outside the AI Act deployer definition, so this generator treats the label as voluntary for Article 50 purposes. Other law may still apply.",
|
||||
"legal_assessment_pre_application": "The supplied output date is before 2 August 2026. The Commission FAQ states that content generated before that date does not need to be labelled retroactively under Article 50. Voluntary transparency and other legal duties remain possible.",
|
||||
"legal_assessment_provider": "The supplied role is provider-only. This publication badge cannot establish compliance with provider-side Article 50 duties, especially machine-readable marking of in-scope generated or manipulated outputs. A separate provider assessment is required, including the limited Article 50(2) exception for standard editing assistance or non-substantial alteration of the deployer's input data or semantics.",
|
||||
"legal_assessment_law_enforcement": "A qualifying legal authorisation for criminal-law purposes is stated. Article 50(4) provides a specific exception for that use, so this generator does not treat the content-specific deployer disclosure as required on that basis. The authorisation should be documented and other duties remain unaffected.",
|
||||
"validation_law_enforcement_unsure": "The criminal-law authorisation is unclear. Do not rely on the Article 50(4) exception until the specific legal basis and scope have been verified.",
|
||||
"legal_enforcement_title": "Possible legal consequences",
|
||||
"legal_enforcement_help": "From 2 August 2026, infringements of Article 50 are within the Article 99(4) penalty framework: administrative fines may reach EUR 15 million or, for an undertaking, 3% of total worldwide annual turnover for the preceding financial year (subject to the AI Act’s SME rule and the circumstances of the individual case). The competent authority determines whether an infringement exists and any measure or fine.",
|
||||
"legal_disclaimer": "Decision aid, not legal advice: the result is based only on the supplied facts and the current Article 50 text / Commission guidance. Borderline cases require an individual legal assessment.",
|
||||
"fact_author": "Author",
|
||||
"fact_complaints_contact": "Complaint / feedback contact",
|
||||
"fact_complaints_email": "Complaint / feedback email",
|
||||
"fact_legal_role": "AI Act role (self-assessment)",
|
||||
"fact_use_context": "Use context (self-assessment)",
|
||||
"fact_output_date": "AI output date",
|
||||
"fact_deepfake_assessment": "Deepfake assessment",
|
||||
"fact_public_interest_assessment": "Public-interest text assessment",
|
||||
"fact_creative_work_assessment": "Creative/satirical/fictional work assessment",
|
||||
"fact_law_enforcement_authorization": "Criminal-law authorisation (self-assessment)",
|
||||
}
|
||||
de := map[string]string{
|
||||
"generator_step_content": "1. Inhalt und KI-Nutzung",
|
||||
"generator_step_legal": "2. Anwendungsbereich von Art. 50 prüfen",
|
||||
"generator_step_accountability": "3. Veröffentlichung und Verantwortlichkeit",
|
||||
"generator_step_output": "4. Ausgabe",
|
||||
"field_required": "Für diese Einordnung erforderlich",
|
||||
"select_please_choose": "Bitte auswählen",
|
||||
"field_optional": "Optional",
|
||||
"field_best_practice": "Best Practice",
|
||||
"assessment_yes": "Ja",
|
||||
"assessment_no": "Nein",
|
||||
"assessment_unsure": "Unklar / muss geprüft werden",
|
||||
"field_legal_role": "Deine Rolle nach dem AI Act",
|
||||
"legal_role_deployer": "Deployer / Betreiber – ich setze ein KI-System unter eigener Verantwortung ein",
|
||||
"legal_role_provider": "Provider / Anbieter – ich biete das KI-System an oder bringe es in Verkehr",
|
||||
"legal_role_both": "Sowohl Provider als auch Deployer",
|
||||
"legal_role_unsure": "Unklar / muss geprüft werden",
|
||||
"legal_role_help": "Art. 50 weist Providern und Deployern unterschiedliche Pflichten zu. Dieser Generator dokumentiert vor allem veröffentlichungsbezogene Offenlegungen; die maschinenlesbare Anbieterkennzeichnung ist eigenständig. Art. 50 Abs. 2 enthält außerdem eine begrenzte Ausnahme für Standard-Bearbeitungsfunktionen bzw. Ausgaben, die Eingabedaten oder deren Semantik nicht wesentlich verändern.",
|
||||
"field_use_context": "Nutzungskontext",
|
||||
"use_context_professional": "Berufliche / organisatorische Nutzung",
|
||||
"use_context_personalNonProfessional": "Rein persönliche, nicht berufliche Nutzung",
|
||||
"use_context_unsure": "Unklar / gemischte Nutzung",
|
||||
"use_context_help": "Wer KI ausschließlich persönlich und nicht beruflich nutzt, ist grundsätzlich kein Deployer im Sinne des AI Act. Regelmäßiger wirtschaftlicher, geschäftlicher, beruflicher oder freiberuflicher Einsatz kann dagegen beruflich sein.",
|
||||
"field_output_date": "Datum der Erzeugung oder Manipulation des betroffenen KI-Outputs",
|
||||
"output_date_help": "Art. 50 gilt ab 2. August 2026. Nach der FAQ der Kommission müssen vor diesem Datum erzeugte Inhalte nach Art. 50 nicht rückwirkend gekennzeichnet werden.",
|
||||
"legal_scope_intro": "Die folgenden Fragen erscheinen nur, wenn sie zu den gewählten Inhaltsbestandteilen passen. Sie unterstützen eine vorsichtige Selbsteinordnung und sind keine Rechtsberatung.",
|
||||
"legal_deepfake_not_relevant": "Die Deepfake-Prüfung wird nur bei KI-beteiligten Bild-, Audio- oder Videoinhalten eingeblendet.",
|
||||
"legal_public_not_relevant": "Die Prüfung für Texte von öffentlichem Interesse wird nur eingeblendet, wenn beim Text eine KI-Beteiligung angegeben ist.",
|
||||
"law_enforcement_title": "Gesetzlich autorisierte Strafverfolgungsnutzung",
|
||||
"field_law_enforcement_authorization": "Ist diese Nutzung gesetzlich zur Aufdeckung, Verhütung, Ermittlung oder Verfolgung von Straftaten autorisiert?",
|
||||
"law_enforcement_no": "Nein",
|
||||
"law_enforcement_yes": "Ja – gesetzlich autorisiert",
|
||||
"law_enforcement_unsure": "Unklar / rechtlich zu prüfen",
|
||||
"law_enforcement_help": "Art. 50 Abs. 4 enthält hierfür eine besondere Ausnahme. „Ja“ sollte nicht allein deshalb gewählt werden, weil eine Behörde veröffentlicht; erforderlich ist eine konkrete gesetzliche Autorisierung der betreffenden Nutzung.",
|
||||
"editorial_exception_title": "Mögliche Ausnahme bei Texten von öffentlichem Interesse",
|
||||
"editorial_exception_help": "Für die Ausnahme nach Art. 50 Abs. 4 müssen sowohl menschliche Prüfung/redaktionelle Kontrolle als auch die rechtliche redaktionelle Verantwortung einer natürlichen oder juristischen Person für die Veröffentlichung vorliegen. Oberflächliche oder rein formale Prüfungen genügen nicht.",
|
||||
"responsibility_required_help": "Wird eingeblendet, weil ein Public-Interest-Text und eine substanzielle Prüfung angegeben wurden. Benenne die natürliche oder juristische Person, die letztlich die rechtliche redaktionelle Verantwortung für die Veröffentlichung trägt, wenn die Ausnahme berücksichtigt werden soll.",
|
||||
"field_author": "Autor/in / Byline",
|
||||
"field_author_url": "Autorprofil / Referenz-URL",
|
||||
"author_help": "Autor/in und rechtlich redaktionell verantwortliche Person/Organisation sind nicht automatisch identisch. Das ist eine Veröffentlichungsangabe und keine allgemeine Pflicht aus Art. 50.",
|
||||
"accountability_intro": "Diese Angaben sind bewusst von Art.-50-Pflichten getrennt. Sie verbessern die Nachvollziehbarkeit, ohne zu behaupten, dass jede Veröffentlichung sie zwingend enthalten muss.",
|
||||
"complaints_title": "Beschwerde- / Rückmeldestelle",
|
||||
"complaints_help": "Best Practice für transparente Veröffentlichungen. Art. 50 begründet keine allgemeine Pflicht, bei jedem gekennzeichneten Inhalt eine interne Beschwerdestelle zu benennen. Dieser freiwillige Kontakt ersetzt auch nicht das gesonderte Beschwerderecht nach Art. 85 AI Act gegenüber einer Marktüberwachungsbehörde; Medien-, Verbraucher-, Plattform- oder sonstiges Fachrecht kann zusätzliche Pflichten vorsehen.",
|
||||
"field_complaint_name": "Anlaufstelle / Organisation",
|
||||
"field_complaint_email": "Beschwerde- / Feedback-E-Mail",
|
||||
"field_complaint_url": "Beschwerde- / Feedback-URL",
|
||||
"validation_heading": "Plausibilitäts- und Rechtshinweise",
|
||||
"validation_formal_review": "Es ist nur eine grundlegende/formale Prüfung dokumentiert. Nach den Kommissionsleitlinien reichen Rechtschreibung, Grammatik oder andere oberflächliche Prüfungen für die Public-Interest-Ausnahme nicht als menschliche Prüfung/redaktionelle Kontrolle aus.",
|
||||
"validation_missing_responsibility": "Eine substanzielle Prüfung ist ausgewählt, die redaktionelle Verantwortung aber nicht vollständig benannt. Die Ausnahme für Public-Interest-Texte sollte auf dieser Grundlage nicht unterstellt werden.",
|
||||
"validation_provider": "Provider-Rolle ausgewählt: Ein sichtbares Badge oder JSON-LD ersetzt nicht die anbieterseitige Pflicht zur maschinenlesbaren Markierung einschlägiger generativer KI-Ausgaben.",
|
||||
"validation_role_unsure": "Die AI-Act-Rolle ist noch unklar. Deployer-seitige Offenlegungspflichten sollten nicht ausgeschlossen werden, bevor geklärt ist, ob das KI-System im beruflichen/organisatorischen Kontext unter eigener Verantwortung eingesetzt wird.",
|
||||
"validation_use_context_unsure": "Der Nutzungskontext ist unklar oder gemischt. Berufliche, geschäftliche, freiberufliche oder sonst wirtschaftliche Nutzung sollte von rein persönlicher, nicht beruflicher Nutzung getrennt betrachtet werden, bevor auf die persönliche Nutzungsausnahme vertraut wird.",
|
||||
"validation_personal": "Rein persönliche, nicht berufliche Nutzung fällt grundsätzlich nicht unter den Deployer-Begriff des AI Act. Eine freiwillige Transparenzkennzeichnung kann dennoch sinnvoll sein; anderes Recht kann weiterhin gelten.",
|
||||
"validation_pre_application": "Das angegebene Output-Datum liegt vor dem 2. August 2026. Nach der Kommissions-FAQ müssen solche Inhalte nach Art. 50 nicht rückwirkend gekennzeichnet werden; andere Rechtspflichten bleiben unberührt.",
|
||||
"validation_public_no_ai": "Public-Interest-Text ist ausgewählt, aber für den Text ist keine KI-Beteiligung dokumentiert. Bitte Inhaltsangaben prüfen oder die Kategorie entfernen.",
|
||||
"validation_deepfake_no_media": "Deepfake ist ausgewählt, aber es ist kein KI-beteiligtes Bild, Audio oder Video dokumentiert. Bitte Inhaltsangaben prüfen oder die Kategorie entfernen.",
|
||||
"validation_deepfake_unsure": "Die Deepfake-Einordnung ist offen. Vor einem „Nein“ sollten insbesondere Ähnlichkeit, reales oder plausibel reales Vorbild, der falsche Eindruck von Authentizität/Wahrheit sowie Veröffentlichungskontext und Publikumserwartung geprüft werden.",
|
||||
"validation_public_unsure": "Die Public-Interest-Einordnung ist offen. Vor einem „Nein“ sollte geprüft werden, ob der Text veröffentlicht wird, die Öffentlichkeit informieren soll und eine Angelegenheit von öffentlichem Interesse betrifft.",
|
||||
"validation_creative_unsure": "Die Einordnung als evident künstlerisch/kreativ/satirisch/fiktional ist offen. Sie betrifft bei Deepfakes die Art der Offenlegung, nicht die grundsätzliche Transparenzpflicht als solche.",
|
||||
"validation_complaint_incomplete": "Bitte die Beschwerde- / Rückmeldestelle mit Name und mindestens einer gültigen E-Mail-Adresse oder URL vervollständigen.",
|
||||
"consequence_disclosure": "Voraussichtliche Konsequenz: Einen klaren, unterscheidbaren Hinweis spätestens bei der ersten Exposition zum betroffenen Inhalt anzeigen; Barrierefreiheitsanforderungen sind ebenfalls zu beachten.",
|
||||
"consequence_provider": "Anbieter-Konsequenz: Die eigenständigen Anforderungen aus Art. 50 Abs. 2 an maschinenlesbare Markierungen prüfen und dabei gesondert bewerten, ob die enge Ausnahme für Standard-Bearbeitung bzw. nicht wesentliche Änderungen tatsächlich greift. Dieser Generator ist nur ergänzende Dokumentation.",
|
||||
"consequence_voluntary": "Aus den Angaben ergibt sich keine inhaltsspezifische Pflicht nach Art. 50 Abs. 4. Freiwillige Transparenz bleibt möglich; andere AI-Act- oder Fachpflichten können dennoch gelten.",
|
||||
"legal_assessment_pending": "Die Art.-50-Einordnung ist noch nicht vollständig. Beantworte die aktuell als erforderlich markierten Fragen; bis dahin trifft der Generator keine Aussage dazu, ob eine Kennzeichnungspflicht besteht oder nicht besteht.",
|
||||
"legal_assessment_personal": "Als Nutzungskontext wurde eine rein persönliche, nicht berufliche Nutzung angegeben. Diese fällt grundsätzlich nicht unter den Deployer-Begriff des AI Act; für Art. 50 behandelt der Generator die Kennzeichnung daher als freiwillig. Anderes Recht kann trotzdem gelten.",
|
||||
"legal_assessment_pre_application": "Das angegebene Output-Datum liegt vor dem 2. August 2026. Nach der FAQ der Kommission müssen vor diesem Datum erzeugte Inhalte nach Art. 50 nicht rückwirkend gekennzeichnet werden. Freiwillige Transparenz und andere Rechtspflichten bleiben möglich.",
|
||||
"legal_assessment_provider": "Es wurde ausschließlich die Provider-/Anbieterrolle angegeben. Dieses Veröffentlichungs-Badge kann die anbieterseitigen Art.-50-Pflichten, insbesondere die maschinenlesbare Markierung einschlägiger generierter oder manipulierter Outputs, nicht erfüllen. Dafür ist eine gesonderte Anbieterprüfung erforderlich, einschließlich der engen Ausnahme nach Art. 50 Abs. 2 für Standard-Bearbeitung bzw. nicht wesentliche Änderungen von Eingabedaten oder deren Semantik.",
|
||||
"legal_assessment_law_enforcement": "Es wurde eine einschlägige gesetzliche Autorisierung für Strafverfolgungszwecke angegeben. Art. 50 Abs. 4 enthält dafür eine besondere Ausnahme; der Generator behandelt die inhaltsspezifische Deployer-Offenlegung auf dieser Grundlage daher nicht als erforderlich. Die Rechtsgrundlage sollte dokumentiert werden; andere Pflichten bleiben unberührt.",
|
||||
"legal_assessment_deepfake_unsure": "Es ist unklar, ob der KI-beteiligte Medieninhalt als Deepfake einzuordnen ist. Eine inhaltsspezifische Pflicht nach Art. 50 Abs. 4 kann anhand der Angaben nicht ausgeschlossen werden. Bis zur Klärung ist eine klare Offenlegung die vorsichtigere Veröffentlichungseinstellung.",
|
||||
"legal_assessment_public_unsure": "Es ist unklar, ob der KI-beteiligte Text veröffentlicht wird, um die Öffentlichkeit über eine Angelegenheit von öffentlichem Interesse zu informieren. Eine inhaltsspezifische Pflicht nach Art. 50 Abs. 4 kann anhand der Angaben nicht ausgeschlossen werden. Bis zur Klärung ist eine klare Offenlegung die vorsichtigere Veröffentlichungseinstellung.",
|
||||
"validation_law_enforcement_unsure": "Die gesetzliche Autorisierung für Strafverfolgungszwecke ist unklar. Auf die Ausnahme nach Art. 50 Abs. 4 sollte erst vertraut werden, wenn konkrete Rechtsgrundlage und Anwendungsbereich geprüft sind.",
|
||||
"legal_enforcement_title": "Mögliche rechtliche Konsequenzen",
|
||||
"legal_enforcement_help": "Ab 2. August 2026 fallen Verstöße gegen Art. 50 grundsätzlich in den Bußgeldrahmen des Art. 99 Abs. 4: bis zu 15 Mio. EUR oder bei Unternehmen bis zu 3 % des weltweiten Jahresgesamtumsatzes des vorangegangenen Geschäftsjahres (unter Beachtung der besonderen KMU-Regel und der Umstände des Einzelfalls). Ob ein Verstoß vorliegt und welche Maßnahme oder Geldbuße verhängt wird, entscheidet die zuständige Behörde.",
|
||||
"legal_disclaimer": "Entscheidungshilfe, keine Rechtsberatung: Die Einschätzung beruht ausschließlich auf den eingegebenen Tatsachen sowie dem aktuellen Art. 50 und den Kommissionsleitlinien. Grenzfälle benötigen eine individuelle rechtliche Prüfung.",
|
||||
"fact_author": "Autor/in",
|
||||
"fact_complaints_contact": "Beschwerde- / Rückmeldestelle",
|
||||
"fact_complaints_email": "Beschwerde- / Feedback-E-Mail",
|
||||
"fact_legal_role": "AI-Act-Rolle (Selbsteinordnung)",
|
||||
"fact_use_context": "Nutzungskontext (Selbsteinordnung)",
|
||||
"fact_output_date": "Datum des KI-Outputs",
|
||||
"fact_deepfake_assessment": "Deepfake-Einordnung",
|
||||
"fact_public_interest_assessment": "Public-Interest-Text-Einordnung",
|
||||
"fact_creative_work_assessment": "Kreativ-/Satire-/Fiktions-Einordnung",
|
||||
"fact_law_enforcement_authorization": "Strafverfolgungs-Autorisierung (Selbsteinordnung)",
|
||||
}
|
||||
for code := range catalogs {
|
||||
if code == "de" {
|
||||
continue
|
||||
}
|
||||
l := catalogs[code]
|
||||
for k, v := range en {
|
||||
l.Text[k] = v
|
||||
}
|
||||
catalogs[code] = l
|
||||
}
|
||||
l := catalogs["de"]
|
||||
for k, v := range de {
|
||||
l.Text[k] = v
|
||||
}
|
||||
catalogs["de"] = l
|
||||
}
|
||||
|
||||
func applyThemeTranslations() {
|
||||
values := map[string]string{
|
||||
"de": "Emoji (quadratisch)",
|
||||
"en": "Emoji (square)",
|
||||
"fr": "Emoji (carré)",
|
||||
"es": "Emoji (cuadrado)",
|
||||
"it": "Emoji (quadrato)",
|
||||
"nl": "Emoji (vierkant)",
|
||||
"pt": "Emoji (quadrado)",
|
||||
"pl": "Emoji (kwadratowe)",
|
||||
}
|
||||
for code, value := range values {
|
||||
l := catalogs[code]
|
||||
l.Text["theme_emoji"] = value
|
||||
catalogs[code] = l
|
||||
}
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
}
|
||||
|
||||
func applyArticleTranslations() {
|
||||
|
||||
@@ -126,25 +126,14 @@ func Build(lang, productName, baseURL, contactURL string) Page {
|
||||
# Adjust BASE_URL and PUBLIC_NAME in .env
|
||||
docker compose up -d --build
|
||||
curl -fsS http://localhost:8080/readyz`},
|
||||
<<<<<<< HEAD
|
||||
{ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:2.0.0 .
|
||||
=======
|
||||
{ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:1.8.2 .
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
docker run -d --name ai-disclosure \
|
||||
-p 8080:8080 \
|
||||
-e BASE_URL=https://ai.example.org \
|
||||
-e PUBLIC_NAME="AI Usage Disclosure" \
|
||||
--read-only --tmpfs /tmp \
|
||||
<<<<<<< HEAD
|
||||
ai-disclosure-standard:2.0.0`},
|
||||
{ID: "kubernetes", Title: copy.InstallTitles[2], Summary: copy.InstallSummaries[2], Code: `# Image, domain and TLS secret in deploy/kubernetes.yaml ersetzen
|
||||
=======
|
||||
ai-disclosure-standard:1.8.2`},
|
||||
{ID: "kubernetes", Title: copy.InstallTitles[2], Summary: copy.InstallSummaries[2], Code: `# Image, Domain und TLS-Secret in deploy/kubernetes.yaml ersetzen
|
||||
kubectl create secret generic ai-disclosure-license \
|
||||
--from-literal=token='...'
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
kubectl apply -f deploy/kubernetes.yaml
|
||||
kubectl rollout status deployment/ai-disclosure
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AI Usage Disclosure API
|
||||
<<<<<<< HEAD
|
||||
version: 2.0.0
|
||||
description: >-
|
||||
Stateless multilingual API for visible and machine-readable AI usage declarations,
|
||||
non-binding Article 50 decision support, validation and optional licensed bulk/export capabilities.
|
||||
=======
|
||||
version: 1.8.2
|
||||
description: Stateless multilingual badge, declaration and validation API with optional licensed presentation capabilities.
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
servers:
|
||||
- url: https://ai.example.org
|
||||
paths:
|
||||
@@ -128,15 +123,10 @@ paths:
|
||||
"403": {$ref: '#/components/responses/LicensedCapabilityRequired'}
|
||||
/v1/article50-assessment.json:
|
||||
get:
|
||||
<<<<<<< HEAD
|
||||
summary: Return non-binding technical Article 50 decision support
|
||||
description: >-
|
||||
Evaluates the factual regulatory context supplied with a declaration. The result is
|
||||
technical decision support, not legal advice or a legal determination.
|
||||
=======
|
||||
summary: Render reusable embedding artifacts from declaration query parameters
|
||||
description: Returns accessible visible HTML, a one-step HTML embed with inline JSON-LD, Markdown and the parsed JSON-LD declaration from the same core logic used by the normal generator. The optional bulk container calls this endpoint for each subject URL.
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/commonMode'}
|
||||
- {$ref: '#/components/parameters/preset'}
|
||||
@@ -169,7 +159,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
<<<<<<< HEAD
|
||||
required: [assessment, title, summary, disclaimer]
|
||||
properties:
|
||||
assessment: {$ref: '#/components/schemas/Article50Assessment'}
|
||||
@@ -251,20 +240,6 @@ paths:
|
||||
description: Bulk API authentication required when configured
|
||||
"403": {$ref: '#/components/responses/LicensedCapabilityRequired'}
|
||||
"413": {description: Configured or licensed bulk limit exceeded}
|
||||
=======
|
||||
required: [declarationUrl, badgeUrl, manifestUrl, accessibleText, html, embedHtml, markdown, jsonLd]
|
||||
properties:
|
||||
subject: {type: string, format: uri}
|
||||
declarationUrl: {type: string, format: uri}
|
||||
badgeUrl: {type: string, format: uri}
|
||||
manifestUrl: {type: string, format: uri}
|
||||
accessibleText: {type: string}
|
||||
html: {type: string, description: Visible accessible disclosure markup without JSON-LD}
|
||||
embedHtml: {type: string, description: Recommended one-step embed containing the visible disclosure and inline JSON-LD}
|
||||
markdown: {type: string}
|
||||
jsonLd: {$ref: './schema/declaration.schema.json'}
|
||||
"403": {$ref: '#/components/responses/ProRequired'}
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
/v1/validate:
|
||||
post:
|
||||
summary: Validate an AI usage declaration
|
||||
|
||||
+1
-125
@@ -5,12 +5,6 @@
|
||||
const form = document.getElementById('generator-form');
|
||||
if (!form) return;
|
||||
const $ = id => document.getElementById(id);
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
const ARTICLE_50_START = '2026-08-02';
|
||||
let renderSequence = 0;
|
||||
let renderTimer = 0;
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
const presets = {
|
||||
'no-ai': {extent:'none', activities:'', review:'editorial'},
|
||||
research: {extent:'assisted', activities:'research', review:'editorial'},
|
||||
@@ -23,97 +17,6 @@
|
||||
return el && !el.disabled ? el.value.trim() : '';
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
function validValue(id) {
|
||||
const el = $(id);
|
||||
if (!el || el.disabled) return '';
|
||||
const v = el.value.trim();
|
||||
return v && (!el.validity || el.validity.valid) ? v : '';
|
||||
}
|
||||
|
||||
function setHidden(id, hidden) {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.hidden = hidden;
|
||||
el.querySelectorAll?.('input,select,textarea').forEach(input => { input.disabled = hidden; });
|
||||
}
|
||||
|
||||
function hexRGB(value) {
|
||||
if (!/^#[0-9a-f]{6}$/i.test(value || '')) return null;
|
||||
return [1, 3, 5].map(i => parseInt(value.slice(i, i + 2), 16) / 255);
|
||||
}
|
||||
|
||||
function relativeLuminance(value) {
|
||||
const rgb = hexRGB(value);
|
||||
if (!rgb) return null;
|
||||
const channels = rgb.map(c => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
function contrastRatio(foreground, background) {
|
||||
const a = relativeLuminance(foreground);
|
||||
const b = relativeLuminance(background);
|
||||
if (a == null || b == null) return null;
|
||||
const lighter = Math.max(a, b);
|
||||
const darker = Math.min(a, b);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function emojiBackground() {
|
||||
if (articleMode()) return '#7c3aed';
|
||||
const presetColors = {'no-ai':'#2f855a', research:'#2b6cb0', summary:'#b7791f', full:'#c53030'};
|
||||
const extentColors = {none:'#2f855a', assisted:'#2b6cb0', partial:'#b7791f', mostly:'#c05621', full:'#c53030'};
|
||||
return presetColors[$('preset').value] || extentColors[$('extent').value] || '#4a5568';
|
||||
}
|
||||
|
||||
function updateContrastStatus(locale) {
|
||||
const target = $('badge-contrast-status');
|
||||
if (!target) return;
|
||||
target.hidden = true;
|
||||
target.textContent = '';
|
||||
if ($('theme').value !== 'emoji') return;
|
||||
const foreground = validValue('left-color') || '#ffffff';
|
||||
const background = validValue('right-color') || emojiBackground();
|
||||
const ratio = contrastRatio(foreground, background);
|
||||
if (ratio != null && ratio < 3) {
|
||||
target.textContent = locale.text.contrast_adjusted || 'The selected emoji colours have insufficient contrast; the icon will automatically use black or white.';
|
||||
target.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRenderedArtifacts(params, theme, locale) {
|
||||
const sequence = ++renderSequence;
|
||||
clearTimeout(renderTimer);
|
||||
const rendering = locale.text.embed_rendering || 'Rendering embed …';
|
||||
$('html-code').value = rendering;
|
||||
$('markdown-code').value = rendering;
|
||||
$('json-code').value = rendering;
|
||||
renderTimer = setTimeout(async () => {
|
||||
const renderParams = new URLSearchParams(params);
|
||||
renderParams.set('theme', theme);
|
||||
try {
|
||||
const response = await fetch(`${base}/v1/render?${renderParams}`, {headers: {'Accept': 'application/json'}});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.detail || `HTTP ${response.status}`);
|
||||
if (sequence !== renderSequence) return;
|
||||
$('html-code').value = payload.embedHtml || payload.html || '';
|
||||
$('markdown-code').value = payload.markdown || '';
|
||||
$('json-code').value = payload.jsonLd ? JSON.stringify(payload.jsonLd, null, 2) : '';
|
||||
if (payload.accessibleText) $('preview-text').textContent = payload.accessibleText;
|
||||
if (payload.declarationUrl) $('preview-link').href = payload.declarationUrl;
|
||||
if (payload.badgeUrl) $('preview-badge').src = payload.badgeUrl;
|
||||
} catch (error) {
|
||||
if (sequence !== renderSequence) return;
|
||||
const message = locale.text.embed_render_error || 'The embed could not be rendered.';
|
||||
$('html-code').value = `${message} ${error.message}`;
|
||||
$('markdown-code').value = message;
|
||||
$('json-code').value = message;
|
||||
}
|
||||
}, 140);
|
||||
}
|
||||
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
function setArticleParams(params) {
|
||||
params.set('mode', 'article');
|
||||
const fields = {
|
||||
@@ -193,27 +96,14 @@
|
||||
badgeParams.set('theme', $('theme').value);
|
||||
badgeParams.set('link', declarationURL);
|
||||
const badgeURL = `${base}/v1/badge.svg?${badgeParams}`;
|
||||
<<<<<<< HEAD
|
||||
const manifestURL = `${base}/v1/declaration.json?${params}`;
|
||||
let alt = articleMode ? (locale.text.badge_article || 'Article transparency') : (locale.text.ai_label || 'AI usage disclosure');
|
||||
if ($('deepfake')?.checked) alt = locale.text.badge_article50_deepfake || alt;
|
||||
else if ($('public-interest-text')?.checked && !($('substantial-review')?.checked && $('editorial-responsibility-confirmed')?.checked)) alt = locale.text.badge_article50_text || alt;
|
||||
=======
|
||||
const assessment = legalAssessment(locale);
|
||||
renderAssessment(locale, assessment);
|
||||
|
||||
const badgeText = assessment.legal
|
||||
? assessment.badge
|
||||
: (isArticle
|
||||
? (locale.text.badge_article || 'Article transparency')
|
||||
: ((locale.presets[preset] && locale.presets[preset].title) || locale.extents[$('extent').value] || 'AI usage disclosure'));
|
||||
const accessibleText = `${badgeText} – ${locale.text.embed_details || 'AI usage details'}`;
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
|
||||
$('preview-badge').src = badgeURL;
|
||||
$('preview-badge').alt = '';
|
||||
$('preview-badge').alt = alt;
|
||||
$('preview-link').href = declarationURL;
|
||||
<<<<<<< HEAD
|
||||
$('html-code').value = `<a href="${declarationURL}"><img src="${badgeURL}" alt="${alt}"></a>`;
|
||||
$('markdown-code').value = `[](${declarationURL})`;
|
||||
$('json-code').value = manifestURL;
|
||||
@@ -229,20 +119,6 @@
|
||||
reviewSelect.value = 'none';
|
||||
} else if (reviewSelect.value === 'none') {
|
||||
reviewSelect.value = 'editorial';
|
||||
=======
|
||||
$('preview-text').textContent = accessibleText;
|
||||
updateContrastStatus(locale);
|
||||
scheduleRenderedArtifacts(params, $('theme').value, locale);
|
||||
const bulkLink = $('bulk-template-link');
|
||||
if (bulkLink) {
|
||||
bulkLink.hidden = !cfg.bulkURL;
|
||||
if (cfg.bulkURL) {
|
||||
const bulkParams = new URLSearchParams(params);
|
||||
bulkParams.delete('subject');
|
||||
bulkParams.delete('link');
|
||||
const payload = {version: 1, query: bulkParams.toString(), theme: $('theme').value};
|
||||
bulkLink.href = `${cfg.bulkURL}/#template=${encodeURIComponent(JSON.stringify(payload))}`;
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
}
|
||||
update(false);
|
||||
});
|
||||
|
||||
@@ -69,25 +69,4 @@
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
<<<<<<< HEAD
|
||||
.assessment-findings li { line-height: 1.55; }
|
||||
=======
|
||||
.compliance-notice { margin-top: 14px; }
|
||||
|
||||
/* Article 50 context and cautious assessment */
|
||||
.legal-context-builder{border:1px solid var(--line);border-radius:14px;padding:22px;background:#fbfaf7}.legal-context-builder legend{padding:0 8px;font-weight:800}.legal-context-builder>p{margin-top:0;color:var(--muted)}.generator-grid .check-row{display:grid;grid-template-columns:22px 1fr;gap:10px;align-items:start;padding:10px 0;font-weight:400}.check-row input{width:18px;height:18px;margin-top:3px}.check-row span{display:flex;flex-direction:column;gap:3px}.check-row strong{font-weight:800}.legal-assessment{margin-top:14px;border-radius:12px;padding:16px 18px;background:#eeeae2;display:flex;flex-direction:column;gap:6px}.legal-assessment[data-level="attention"]{border-left:4px solid #8f2d1f}.legal-assessment[data-level="info"]{border-left:4px solid #5d5a50}.legal-assessment-section{margin-top:32px}.legal-assessment-card{border:1px solid var(--line);border-radius:14px;padding:20px;background:#fbfaf7}.legal-assessment-card.attention{border-left:5px solid #8f2d1f}.legal-assessment-card.info{border-left:5px solid #5d5a50}
|
||||
|
||||
/* Conditional generator workflow */
|
||||
[hidden]{display:none!important}
|
||||
.form-step{border:1px solid var(--line);border-radius:16px;padding:22px;background:#fff}.form-step>legend{padding:0 10px;font-weight:900;font-size:1.05rem}.form-step>p{margin-top:0;color:var(--muted)}
|
||||
.field-badge{display:inline-flex;width:max-content;align-items:center;border-radius:999px;padding:2px 8px;margin-left:6px;font-size:.68rem;line-height:1.5;font-weight:800;vertical-align:middle;background:#eeeae2;color:#4a473f}.field-badge.required{background:#fff0ed;color:#7a261c}.field-badge.best-practice{background:#eef4ff;color:#294f85}.field-badge.optional{background:#f2f1ed;color:#5d5a50}
|
||||
.field-help{font-weight:400;margin-top:2px}.field-help summary{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:1px solid var(--line);border-radius:999px;cursor:pointer;font-weight:900;color:var(--muted);list-style:none}.field-help summary::-webkit-details-marker{display:none}.field-help[open] summary{margin-bottom:7px}.field-help span{display:block;padding:10px 12px;border-radius:10px;background:#f3f1ec;color:var(--muted);line-height:1.5}
|
||||
.legal-category-list{display:grid;gap:10px;margin-top:18px}.legal-question{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:#fff}.legal-question label{display:block}.legal-question small{display:block;margin-top:6px;color:var(--muted);font-weight:400;line-height:1.45}.legal-question select{margin-top:10px}.conditional-block{margin-top:18px}.conditional-panel{border:1px solid var(--line);border-radius:14px;padding:18px;background:#fbfaf7}.conditional-panel h3{margin:0 0 6px;font-size:1rem}.conditional-panel>p{margin:.3rem 0 1rem;color:var(--muted);font-size:.92rem}
|
||||
.validation-messages{display:grid;gap:8px;margin-top:12px}.validation-message{margin:0;padding:12px 14px;border-radius:10px;background:#fff6ed;border-left:4px solid #9a5d14;font-size:.9rem}.validation-message.info{background:#eef4ff;border-left-color:#4f6f9f}.assessment-consequence{margin-top:5px;padding-top:8px;border-top:1px solid rgba(0,0,0,.09);font-weight:650}
|
||||
input:required,select:required{border-left-width:3px}.article-component label[hidden]{display:none!important}
|
||||
@media(max-width:760px){.form-step{padding:17px}.field-badge{margin-left:0;margin-top:3px}.conditional-panel{padding:15px}}
|
||||
.legal-consequences{margin-top:16px;border:1px solid var(--line);border-radius:12px;background:#f7f4ee;padding:14px 16px}.legal-consequences summary{cursor:pointer;font-weight:850}.legal-consequences p{margin:.8rem 0 0;color:var(--muted);font-size:.9rem;line-height:1.55}.legal-consequences .form-warning{color:#5b3029}
|
||||
|
||||
/* Accessible first-exposure disclosure preview */
|
||||
.disclosure-preview-link{display:inline-flex;align-items:center;gap:.55rem;min-height:36px;padding:4px 7px;max-width:100%;border-radius:7px;text-decoration:underline;text-underline-offset:.16em;line-height:1.35}.disclosure-preview-link img{flex:0 0 auto}.disclosure-preview-link span{font-weight:700}.contrast-status{margin:0;padding:12px 14px;border-left:4px solid #8a5a11;border-radius:10px;background:#fff4e5;color:#5b3b0b;font-size:.9rem;line-height:1.5}.code-tabs textarea#html-code,.code-tabs textarea#json-code{height:180px}
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
|
||||
@@ -158,11 +158,7 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<<<<<<< HEAD
|
||||
<footer><span>{{if not .WhiteLabel}}{{.Background.Footer}}{{end}}</span><span>App 2.0.0 · Schema 1.2 · {{.License.Edition}}</span></footer>
|
||||
=======
|
||||
<footer><span>{{.Background.Footer}} · App 1.8.2 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
<script src="/static/marketing.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -130,25 +130,18 @@
|
||||
<label>{{index .Text "custom_right_color"}}
|
||||
<input id="right-color" maxlength="7" pattern="#[0-9A-Fa-f]{6}" placeholder="#2b6cb0" {{if not .CustomBadge}}disabled{{end}}>
|
||||
</label>
|
||||
{{if .CustomBadge}}<p id="badge-contrast-status" class="contrast-status wide" role="status" aria-live="polite" hidden></p>{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<div class="preview-card">
|
||||
<p class="eyebrow">{{index .Text "preview"}}</p>
|
||||
<a id="preview-link" class="disclosure-preview-link" href="#"><img id="preview-badge" alt="" aria-hidden="true"><span id="preview-text"></span></a>
|
||||
<a id="preview-link" href="#"><img id="preview-badge" alt="{{index .Text "preview"}}"></a>
|
||||
<div class="code-tabs">
|
||||
<<<<<<< HEAD
|
||||
<label>HTML<textarea id="html-code" readonly></textarea></label>
|
||||
<label>Markdown<textarea id="markdown-code" readonly></textarea></label>
|
||||
<label>JSON-LD URL<textarea id="json-code" readonly></textarea></label>
|
||||
<label>Article 50 API<textarea id="assessment-code" readonly></textarea></label>
|
||||
=======
|
||||
<label>{{index .Text "embed_html_label"}}<textarea id="html-code" readonly></textarea></label>
|
||||
<label>{{index .Text "embed_markdown_label"}}<textarea id="markdown-code" readonly></textarea></label>
|
||||
<label>{{index .Text "embed_json_label"}}<textarea id="json-code" readonly></textarea></label>
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
</div>
|
||||
<button id="copy-html" type="button">{{index .Text "copy_html"}}</button>
|
||||
</div>
|
||||
@@ -160,13 +153,8 @@
|
||||
<article><p class="eyebrow">HA</p><h2>{{index .Text "api_stateless_title"}}</h2><p>{{index .Text "api_stateless_desc"}}</p></article>
|
||||
</section>
|
||||
</main>
|
||||
<<<<<<< HEAD
|
||||
<footer><span>{{if not .WhiteLabel}}AI Usage Disclosure · {{end}}App 2.0.0 · Schema 1.2 · {{.License.Edition}}</span><span>{{index .Text "footer_no_legal"}}</span></footer>
|
||||
<script>window.APP_CONFIG={{.AppConfig}};</script>
|
||||
=======
|
||||
<footer><span>AI Usage Disclosure · App 1.8.2 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
|
||||
<script nonce="{{.CSPNonce}}">window.APP_CONFIG={{.AppConfig}};</script>
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -119,11 +119,7 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<<<<<<< HEAD
|
||||
<footer><span>{{if not .WhiteLabel}}{{.Marketing.Footer}}{{end}}</span><span>App 2.0.0 · Schema 1.2 · {{.License.Edition}}</span></footer>
|
||||
=======
|
||||
<footer><span>{{.Marketing.Footer}} · App 1.8.2 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
|
||||
>>>>>>> ec58fc30f840ce85ea06eb3d07457640a84f941c
|
||||
<script src="/static/marketing.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user