KI-Kennzeichnung (Art. 50 KI-VO): Pflicht-Deklaration für Text & Bilder

Selbstdeklaration des KI-Einsatzes je PM (Text) und je Bild inkl.
Submit-Gate im PressReleaseService, versioniertem Audit-Trail
(press_release_ai_declarations) und öffentlichem KI-Label in
Einreicher-Stimme. Bestätigung im Submit-Modal ist Pflicht.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-07-09 13:54:43 +00:00
parent 1822dcbcd1
commit c387048d7e
16 changed files with 833 additions and 3 deletions

View file

@ -0,0 +1,66 @@
<?php
namespace App\Enums;
/**
* Selbst-Deklaration des KI-Einsatzes je PM-Bild (Art. 50 Abs. 4 KI-VO).
*
* Pflichtauswahl beim Bild-Upload (Decision-Update KI-Kennzeichnung,
* 08.07.2026). Bewusste Über-Erfüllung: gelabelt wird jedes deklarierte
* KI-Bild — auch stilisiert/abstrakt, nicht nur fotorealistische Deepfakes.
*/
enum AiImageDeclaration: string
{
case Original = 'original';
case AiGenerated = 'ai_generated';
case AiEdited = 'ai_edited';
/**
* Wortlaut der Auswahloption im Upload-Formular.
*/
public function label(): string
{
return match ($this) {
self::Original => __('Originalfoto'),
self::AiGenerated => __('KI-generiertes Bild'),
self::AiEdited => __('KI-bearbeitetes Bild'),
};
}
/**
* Öffentlicher Bildhinweis direkt am Bild — null bei Originalfotos
* (keine Nicht-KI-Zertifizierung in Portalstimme).
*/
public function publicLabel(): ?string
{
return match ($this) {
self::Original => null,
self::AiGenerated => __('Bildhinweis: KI-generierte Illustration.'),
self::AiEdited => __('Bildhinweis: KI-bearbeitetes Bild.'),
};
}
/**
* Optionsliste für Radios/Selects: value => label.
*
* @return array<string, string>
*/
public static function options(): array
{
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace App\Enums;
/**
* Selbst-Deklaration des KI-Einsatzes beim PM-Text (Art. 50 Abs. 4 KI-VO).
*
* Pflichtauswahl im Submit-Flow (Decision-Update KI-Kennzeichnung, 08.07.2026):
* gelabelt wird allein nach dem deklarierten KI-Grad, nicht nach der
* Public-Interest-Frage — bewusste Über-Erfüllung. Das öffentliche Label
* spricht in Einreicher-Stimme, nie in Portalstimme.
*/
enum AiTextDeclaration: string
{
case None = 'none';
case Assisted = 'assisted';
case MostlyAi = 'mostly_ai';
case FullyAi = 'fully_ai';
/**
* Wortlaut der Auswahloption im Submit-Flow.
*/
public function label(): string
{
return match ($this) {
self::None => __('Ohne generative KI erstellt'),
self::Assisted => __('Mit KI als Formulierungshilfe erstellt und redaktionell überarbeitet'),
self::MostlyAi => __('Überwiegend mit generativer KI erstellt und anschließend geprüft'),
self::FullyAi => __('Vollständig KI-generiert (ohne wesentliche menschliche Bearbeitung)'),
};
}
/**
* Öffentliches Label in Einreicher-Stimme — null, wenn kein Pflicht-Label
* fällig ist ("Ohne KI" wird bewusst nicht als Portal-Aussage zertifiziert,
* die Formulierungshilfe entspricht dem Entlastungsfall).
*/
public function publicLabel(): ?string
{
return match ($this) {
self::None, self::Assisted => null,
self::MostlyAi => __('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung überwiegend mit generativer KI erstellt.'),
self::FullyAi => __('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung vollständig mit generativer KI erstellt.'),
};
}
/**
* Optionsliste für Radios/Selects: value => label.
*
* @return array<string, string>
*/
public static function options(): array
{
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Concerns;
use App\Enums\AiTextDeclaration;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
/**
* KI-Kennzeichnung im Submit-Flow (Art. 50 KI-VO, Decision-Update 08.07.2026).
*
* Gemeinsame Properties + Validierung der vier Customer-Submit-Pfade
* (Erstellen, Bearbeiten, Detailansicht, Liste). Die Selbst-Deklaration ist
* Pflicht bei JEDEM Einreichen: ohne KI-Grad des Textes und verbindliche
* Bestätigung kein Submit. Die Bestätigung wird nicht vorbelegt — sie muss
* bei jedem Submit erneut gesetzt werden (Audit-Trail pro Einreichung).
*/
trait InteractsWithAiDeclaration
{
public ?string $aiTextDeclaration = null;
public bool $aiDeclarationConfirmed = false;
/**
* Pflichtprüfung vor dem Einreichen — Fehler erscheinen direkt im
* Submit-Modal. Entwurf-Speichern bleibt frei von dieser Pflicht.
*/
protected function validateAiDeclaration(): void
{
$this->validate([
'aiTextDeclaration' => ['required', Rule::in(AiTextDeclaration::values())],
'aiDeclarationConfirmed' => ['accepted'],
], [
'aiTextDeclaration.required' => __('Bitte angeben, ob und wie generative KI beim Text eingesetzt wurde.'),
'aiDeclarationConfirmed.accepted' => __('Bitte bestätigen, dass die Angaben zum KI-Einsatz vollständig und wahrheitsgemäß sind.'),
]);
}
/**
* Spaltenwerte für die PM-Persistenz. Der Bestätigungszeitpunkt wird nur
* gestempelt, wenn die Checkbox tatsächlich gesetzt wurde.
*
* @return array{ai_text_declaration: ?string, ai_declaration_confirmed_at: ?Carbon}
*/
protected function aiDeclarationAttributes(): array
{
return [
'ai_text_declaration' => $this->aiTextDeclaration,
'ai_declaration_confirmed_at' => $this->aiDeclarationConfirmed ? now() : null,
];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\AiTextDeclaration;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseContentTier;
@ -71,6 +72,8 @@ class PressRelease extends Model
'placeholder_variant',
'backlink_url',
'keywords',
'ai_text_declaration',
'ai_declaration_confirmed_at',
'status',
'classification',
'classified_at',
@ -95,6 +98,8 @@ class PressRelease extends Model
return [
'portal' => Portal::class,
'placeholder_variant' => PressReleasePlaceholder::class,
'ai_text_declaration' => AiTextDeclaration::class,
'ai_declaration_confirmed_at' => 'datetime',
'status' => PressReleaseStatus::class,
'classification' => PressReleaseClassification::class,
'classified_at' => 'datetime',
@ -166,6 +171,25 @@ class PressRelease extends Model
return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at');
}
/**
* Versionierter Audit-Trail der KI-Deklarationen (Art. 50 KI-VO) —
* jeder Submit erzeugt einen neuen, unveränderlichen Eintrag.
*/
public function aiDeclarations(): HasMany
{
return $this->hasMany(PressReleaseAiDeclaration::class)->orderByDesc('created_at');
}
/**
* Öffentliches KI-Label in Einreicher-Stimme — null, wenn nach dem
* deklarierten KI-Grad kein Pflicht-Label fällig ist (ohne KI /
* Formulierungshilfe) oder noch keine Deklaration vorliegt.
*/
public function aiTextPublicLabel(): ?string
{
return $this->ai_text_declaration?->publicLabel();
}
/**
* Öffentliche Detailseiten je Portal, auf dem die PM erscheint
* (bei Portal::Both beide Spokes). Zentrale Quelle für Mail,

View file

@ -0,0 +1,55 @@
<?php
namespace App\Models;
use App\Enums\AiTextDeclaration;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Unveränderlicher Audit-Eintrag einer KI-Deklaration (Art. 50 KI-VO).
*
* Wird bei jedem Submit geschrieben (Versionierung durch neue Zeilen) und
* hält fest, welcher User wann welchen KI-Grad für Text und Bilder deklariert
* und bestätigt hat. Einträge werden nie aktualisiert oder gelöscht — sie
* sind der belastbare Nachweis gegenüber Art. 50 Abs. 4 KI-VO.
*/
class PressReleaseAiDeclaration extends Model
{
public $timestamps = false;
protected $fillable = [
'press_release_id',
'user_id',
'company_id',
'text_declaration',
'image_declarations',
'confirmed_at',
'created_at',
];
protected function casts(): array
{
return [
'text_declaration' => AiTextDeclaration::class,
'image_declarations' => 'array',
'confirmed_at' => 'datetime',
'created_at' => 'datetime',
];
}
public function pressRelease(): BelongsTo
{
return $this->belongsTo(PressRelease::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function company(): BelongsTo
{
return $this->belongsTo(Company::class);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\AiImageDeclaration;
use App\Enums\ImageLicenseType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -31,6 +32,7 @@ class PressReleaseImage extends Model
'rights_notes',
'rights_confirmed_at',
'is_ai_generated',
'ai_declaration',
'is_preview',
'sort_order',
'width',
@ -48,6 +50,7 @@ class PressReleaseImage extends Model
'persons_consent' => 'boolean',
'rights_confirmed_at' => 'datetime',
'is_ai_generated' => 'boolean',
'ai_declaration' => AiImageDeclaration::class,
'is_preview' => 'boolean',
'sort_order' => 'integer',
'width' => 'integer',

View file

@ -0,0 +1,19 @@
<?php
namespace App\Services\PressRelease;
use RuntimeException;
/**
* KI-Kennzeichnung (Art. 50 KI-VO, Decision-Update 08.07.2026): Ohne
* vollständige Selbst-Deklaration — KI-Grad des Textes, KI-Kennzeichnung
* jedes Bildes und verbindliche Bestätigung — kein Submit. Speichern als
* Entwurf bleibt immer frei.
*/
class AiDeclarationRequiredException extends RuntimeException
{
public function __construct(string $message = 'Für das Einreichen ist die vollständige KI-Kennzeichnung erforderlich (Angabe zum Text, zu jedem Bild sowie die Bestätigung).')
{
parent::__construct($message);
}
}

View file

@ -11,6 +11,8 @@ use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\AdminPreset;
use App\Models\PressRelease;
use App\Models\PressReleaseAiDeclaration;
use App\Models\PressReleaseImage;
use App\Models\PressReleaseStatusLog;
use App\Services\Admin\AdminPerformanceCache;
use Illuminate\Support\Carbon;
@ -52,6 +54,11 @@ class PressReleaseService
throw new QuotaExceededException;
}
// KI-Kennzeichnung (Art. 50 KI-VO): ohne vollständige Deklaration
// kein Submit — die Editoren validieren das bereits im Modal, dieser
// Guard sichert alle Aufrufpfade (API, ältere Entwürfe) ab.
$this->assertAiDeclarationComplete($pressRelease);
$previous = $pressRelease->status;
if ($word = $this->blacklist->findInPressRelease($pressRelease)) {
@ -75,6 +82,11 @@ class PressReleaseService
]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer');
// Versionierter Audit-Trail der KI-Deklaration: jeder Submit erzeugt
// einen neuen, unveränderlichen Eintrag (Abs.-50-Verteidigung —
// "Kunde hat am Datum X Option Y deklariert und bestätigt").
$this->logAiDeclaration($pressRelease);
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand
// des Ergebnisses übernimmt der Job über routeByClassification().
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
@ -397,6 +409,52 @@ class PressReleaseService
}
}
/**
* KI-Kennzeichnung vollständig? Pflicht sind der deklarierte KI-Grad des
* Textes, die verbindliche Bestätigung sowie eine Kennzeichnung für
* jedes Bild (Art. 50 KI-VO, Decision-Update 08.07.2026).
*/
private function assertAiDeclarationComplete(PressRelease $pressRelease): void
{
if ($pressRelease->ai_text_declaration === null || $pressRelease->ai_declaration_confirmed_at === null) {
throw new AiDeclarationRequiredException;
}
if ($pressRelease->images()->whereNull('ai_declaration')->exists()) {
throw new AiDeclarationRequiredException(
__('Bitte für jedes Bild die KI-Kennzeichnung angeben (Originalfoto, KI-generiert oder KI-bearbeitet) — im Bild-Manager der Bearbeiten-Seite.'),
);
}
}
/**
* Schreibt den unveränderlichen Audit-Eintrag der aktuellen Deklaration —
* inkl. Bild-Snapshot zum Submit-Zeitpunkt.
*/
private function logAiDeclaration(PressRelease $pressRelease): void
{
$imageDeclarations = $pressRelease->images()
->orderBy('sort_order')
->get(['id', 'path', 'ai_declaration'])
->map(fn (PressReleaseImage $image): array => [
'image_id' => $image->id,
'path' => $image->path,
'declaration' => $image->ai_declaration?->value,
])
->values()
->all();
PressReleaseAiDeclaration::query()->create([
'press_release_id' => $pressRelease->id,
'user_id' => $pressRelease->user_id,
'company_id' => $pressRelease->company_id,
'text_declaration' => $pressRelease->ai_text_declaration?->value,
'image_declarations' => $imageDeclarations ?: null,
'confirmed_at' => $pressRelease->ai_declaration_confirmed_at,
'created_at' => now(),
]);
}
private function logStatusChange(
PressRelease $pressRelease,
?PressReleaseStatus $from,

View file

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Enums\AiTextDeclaration;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
@ -35,9 +36,25 @@ class PressReleaseFactory extends Factory
'status' => PressReleaseStatus::Draft->value,
'hits' => 0,
'no_export' => false,
// KI-Kennzeichnung (Art. 50 KI-VO): Standard „ohne KI", bestätigt —
// damit erfüllen Test-PMs den Submit-Guard. Gate-Tests setzen die
// Felder explizit auf null (siehe aiUndeclared()).
'ai_text_declaration' => AiTextDeclaration::None->value,
'ai_declaration_confirmed_at' => now(),
];
}
/**
* PM ohne KI-Deklaration — zum Testen des Submit-Gates (Art. 50 KI-VO).
*/
public function aiUndeclared(): static
{
return $this->state([
'ai_text_declaration' => null,
'ai_declaration_confirmed_at' => null,
]);
}
public function published(): static
{
return $this->state([

View file

@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* KI-Kennzeichnung (Art. 50 KI-VO, Decision-Update 08.07.2026):
* Selbst-Deklaration des KI-Grads am PM-Text sowie je Bild. Die Deklaration
* ist Pflicht im Submit-Flow; der Bestätigungszeitpunkt wird zur
* Nachweisbarkeit gespeichert.
*
* Bewusst OHNE ->after(): mit Positionierung kopiert MySQL die große
* press_releases-Tabelle komplett (30+ Min auf Dev), am Tabellenende greift
* ALGORITHM=INSTANT. Die hasColumn-Guards machen die Migration nach einem
* Abbruch mitten im Lauf (08.07.2026 auf Dev passiert) wiederaufnehmbar.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('press_releases', 'ai_text_declaration')) {
Schema::table('press_releases', function (Blueprint $table): void {
$table->string('ai_text_declaration', 20)->nullable();
});
}
if (! Schema::hasColumn('press_releases', 'ai_declaration_confirmed_at')) {
Schema::table('press_releases', function (Blueprint $table): void {
$table->timestamp('ai_declaration_confirmed_at')->nullable();
});
}
if (! Schema::hasColumn('press_release_images', 'ai_declaration')) {
Schema::table('press_release_images', function (Blueprint $table): void {
$table->string('ai_declaration', 20)->nullable();
});
}
}
public function down(): void
{
Schema::table('press_releases', function (Blueprint $table): void {
$table->dropColumn(['ai_text_declaration', 'ai_declaration_confirmed_at']);
});
Schema::table('press_release_images', function (Blueprint $table): void {
$table->dropColumn('ai_declaration');
});
}
};

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Audit-Trail der KI-Deklarationen (Art. 50 KI-VO): Jeder Submit schreibt
* einen unveränderlichen Eintrag — "Kunde hat am Datum X Option Y deklariert
* und bestätigt". Re-Submits erzeugen neue Einträge (Versionierung).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('press_release_ai_declarations', function (Blueprint $table): void {
$table->id();
$table->foreignId('press_release_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('company_id')->nullable()->constrained()->nullOnDelete();
$table->string('text_declaration', 20);
// Snapshot je Bild zum Submit-Zeitpunkt:
// [{image_id, path, declaration}, ...]
$table->json('image_declarations')->nullable();
$table->timestamp('confirmed_at');
$table->timestamp('created_at')->nullable();
});
}
public function down(): void
{
Schema::dropIfExists('press_release_ai_declarations');
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Backfill (DML, getrennt vom DDL): Bilder, die beim Upload bereits als
* KI-generiert lizenziert wurden (is_ai_generated), gelten als deklariert.
* Alle übrigen Bestandsbilder bleiben bewusst NULL — wir deklarieren nicht
* stellvertretend "Originalfoto" für den Kunden; die Deklaration wird beim
* nächsten Submit nachgefordert.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('press_release_images')
->where('is_ai_generated', true)
->whereNull('ai_declaration')
->update(['ai_declaration' => 'ai_generated']);
}
public function down(): void
{
// Bewusst leer: der Backfill ist eine reine Datenanreicherung; das
// Entfernen der Spalte übernimmt die zugehörige DDL-Migration.
}
};

View file

@ -0,0 +1,17 @@
{{--
Öffentliches KI-Label (Art. 50 KI-VO) in Einreicher-Stimme.
Neutrales ℹ-Icon, bewusst KEIN grünes Häkchen — das würde als redaktionelle
Prüfung/Freigabe durch das Portal gelesen. Rendert nichts, wenn kein
Pflicht-Label fällig ist (label = null).
Verwendung: <x-portal.ai-public-label :label="$pr->aiTextPublicLabel()" />
--}}
@props(['label'])
@if ($label)
<div {{ $attributes->merge(['class' => 'flex items-start gap-2 rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2.5 text-[12px] leading-[1.5] text-[color:var(--color-ink-2)]']) }}>
<flux:icon name="information-circle" variant="mini" class="size-4 shrink-0 mt-0.5 text-[color:var(--color-ink-3)]" />
<span>{{ $label }}</span>
</div>
@endif

View file

@ -62,7 +62,7 @@
</div>
</div>
@else
<div class="space-y-5" x-data="{ agb: false, images: false, contact: false, waiver: false }">
<div class="space-y-5" x-data="{ agb: false, images: false, contact: false, waiver: false, ai: false }">
<div>
<flux:text class="eyebrow muted">{{ __('Veröffentlichung') }}</flux:text>
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung einreichen') }}</flux:heading>
@ -104,13 +104,40 @@
</article>
@endif
{{-- KI-Kennzeichnung (Art. 50 KI-VO, Decision-Update 08.07.2026):
Pflicht-Selbstdeklaration in jedem Veröffentlichungsprozess.
Die Eltern-Komponente stellt die Properties über den Trait
App\Livewire\Concerns\InteractsWithAiDeclaration bereit. --}}
<article class="panel overflow-hidden">
<div class="panel-head" style="padding:10px 16px;">
<span class="section-eyebrow">
{{ __('KI-Kennzeichnung') }}
<span class="text-[color:var(--color-err)]">*</span>
</span>
</div>
<div class="p-4 space-y-3">
<p class="m-0 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
{{ __('Wie wurde der Text dieser Pressemitteilung erstellt? Diese Angabe ist gesetzlich verpflichtend (Art. 50 KI-VO).') }}
</p>
<flux:radio.group wire:model="aiTextDeclaration">
@foreach (\App\Enums\AiTextDeclaration::cases() as $aiOption)
<flux:radio :value="$aiOption->value" :label="$aiOption->label()" />
@endforeach
</flux:radio.group>
<flux:error name="aiTextDeclaration" />
<p class="m-0 pt-2 border-t border-[color:var(--color-bg-rule)] text-[11.5px] leading-[1.5] text-[color:var(--color-ink-3)]">
{{ __('Bei überwiegend oder vollständig KI-generierten Texten erscheint an der Veröffentlichung ein neutraler Hinweis in Ihrer Stimme („Nach Angaben des einreichenden Unternehmens …"). KI-generierte oder KI-bearbeitete Bilder kennzeichnen Sie direkt beim Bild-Upload.') }}
</p>
</div>
</article>
{{-- Bestätigungen — Flux-Switches (Einheitlichkeit, 12.06.2026);
der Alpine-State steuert weiterhin den Bestätigen-Button. --}}
<article class="panel overflow-hidden">
<div class="panel-head" style="padding:10px 16px;">
<span class="section-eyebrow">{{ __('Ihre Bestätigungen') }}</span>
<span class="shrink-0 whitespace-nowrap text-[11.5px] tabular-nums text-[color:var(--color-ink-3)]"
x-text="[agb, images, contact, waiver].filter(Boolean).length + ' / 4'"></span>
x-text="[agb, images, contact, waiver, ai].filter(Boolean).length + ' / 5'"></span>
</div>
<div class="divide-y divide-[color:var(--color-bg-rule)]">
<div class="px-4 py-3">
@ -145,6 +172,18 @@
:label="__('Mir ist bekannt, dass veröffentlichte Pressemitteilungen dauerhaft archiviert werden; eine spätere Entfernung ist nur als kostenpflichtige Serviceleistung möglich (gesetzliche Ansprüche bleiben unberührt).')"
/>
</div>
{{-- Verbindliche KI-Bestätigung (Art. 50 KI-VO): wire-gebunden,
weil der Bestätigungszeitpunkt zur Nachweisbarkeit im
Audit-Trail persistiert wird. --}}
<div class="px-4 py-3">
<flux:switch
align="left"
wire:model="aiDeclarationConfirmed"
x-on:change="ai = $event.target.checked"
:label="__('Ich bestätige, dass meine Angaben zum Einsatz generativer KI vollständig und wahrheitsgemäß sind.')"
/>
<flux:error name="aiDeclarationConfirmed" />
</div>
</div>
</article>
@ -154,7 +193,7 @@
</flux:modal.close>
<flux:button variant="primary" wire:click="{{ $action }}"
wire:loading.attr="disabled"
x-bind:disabled="! (agb && images && contact && waiver)">
x-bind:disabled="! (agb && images && contact && waiver && ai)">
{{ $confirmLabel ?? __('Veröffentlichung anfordern') }}
</flux:button>
</div>

View file

@ -463,8 +463,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
@if ($pr->ai_text_declaration)
<span class="text-[11px] text-[color:var(--color-ink-3)]">
{{ __('KI-Deklaration:') }} {{ $pr->ai_text_declaration->label() }}
</span>
@endif
</div>
<div class="p-5">
{{-- KI-Label (Art. 50 KI-VO): erscheint so auch in der
Veröffentlichung — am Anfang des Inhalts, in Einreicher-Stimme. --}}
<x-portal.ai-public-label :label="$pr->aiTextPublicLabel()" class="mb-4 max-w-[760px]" />
<div class="pr-content max-w-[760px]">
{!! $pr->renderedText() !!}
</div>

View file

@ -0,0 +1,289 @@
<?php
use App\Enums\AiImageDeclaration;
use App\Enums\AiTextDeclaration;
use App\Enums\ImageLicenseType;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseAiDeclaration;
use App\Models\User;
use App\Services\PressRelease\AiDeclarationRequiredException;
use App\Services\PressRelease\PressReleaseService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* KI-Kennzeichnung (Art. 50 KI-VO, Decision-Update 08.07.2026):
* Pflicht-Selbstdeklaration im Submit-Flow, versionierter Audit-Trail,
* öffentliches Label in Einreicher-Stimme.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Queue::fake();
});
function makeAiDeclarationCustomer(): array
{
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
return [$customer, $company];
}
test('the text declaration maps to the attributed public label per decision update', function () {
expect(AiTextDeclaration::None->publicLabel())->toBeNull();
expect(AiTextDeclaration::Assisted->publicLabel())->toBeNull();
expect(AiTextDeclaration::MostlyAi->publicLabel())
->toBe('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung überwiegend mit generativer KI erstellt.');
expect(AiTextDeclaration::FullyAi->publicLabel())
->toBe('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung vollständig mit generativer KI erstellt.');
});
test('the image declaration maps to the public image hint per decision update', function () {
expect(AiImageDeclaration::Original->publicLabel())->toBeNull();
expect(AiImageDeclaration::AiGenerated->publicLabel())->toBe('Bildhinweis: KI-generierte Illustration.');
expect(AiImageDeclaration::AiEdited->publicLabel())->toBe('Bildhinweis: KI-bearbeitetes Bild.');
});
test('submit is blocked without a complete ai declaration', function () {
/** @var TestCase $this */
[$customer] = makeAiDeclarationCustomer();
$pr = PressRelease::factory()->aiUndeclared()->create([
'user_id' => $customer->id,
'status' => PressReleaseStatus::Draft->value,
]);
$this->actingAs($customer);
expect(fn () => app(PressReleaseService::class)->submitForReview($pr))
->toThrow(AiDeclarationRequiredException::class);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
expect(PressReleaseAiDeclaration::query()->count())->toBe(0);
});
test('submit is blocked while any image lacks its ai declaration', function () {
/** @var TestCase $this */
[$customer] = makeAiDeclarationCustomer();
$pr = PressRelease::factory()->create([
'user_id' => $customer->id,
'status' => PressReleaseStatus::Draft->value,
]);
$pr->images()->create([
'disk' => 'public',
'path' => 'press-releases/'.$pr->id.'/images/title.jpg',
'author' => 'Jane Doe',
'license_type' => ImageLicenseType::Own->value,
'rights_confirmed_at' => now(),
'is_preview' => true,
'sort_order' => 1,
'ai_declaration' => null,
]);
$this->actingAs($customer);
expect(fn () => app(PressReleaseService::class)->submitForReview($pr))
->toThrow(AiDeclarationRequiredException::class);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
});
test('each submit writes a versioned immutable audit entry with the image snapshot', function () {
/** @var TestCase $this */
[$customer, $company] = makeAiDeclarationCustomer();
$pr = PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'status' => PressReleaseStatus::Draft->value,
'ai_text_declaration' => AiTextDeclaration::MostlyAi->value,
'ai_declaration_confirmed_at' => now(),
]);
$image = $pr->images()->create([
'disk' => 'public',
'path' => 'press-releases/'.$pr->id.'/images/title.jpg',
'author' => 'Beispiel GmbH',
'license_type' => ImageLicenseType::AiGenerated->value,
'rights_confirmed_at' => now(),
'is_ai_generated' => true,
'ai_declaration' => AiImageDeclaration::AiGenerated->value,
'is_preview' => true,
'sort_order' => 1,
]);
$this->actingAs($customer);
app(PressReleaseService::class)->submitForReview($pr);
$entry = PressReleaseAiDeclaration::query()->sole();
expect($entry->press_release_id)->toBe($pr->id);
expect($entry->user_id)->toBe($customer->id);
expect($entry->company_id)->toBe($company->id);
expect($entry->text_declaration)->toBe(AiTextDeclaration::MostlyAi);
expect($entry->confirmed_at)->not->toBeNull();
expect($entry->created_at)->not->toBeNull();
expect($entry->image_declarations)->toBe([
['image_id' => $image->id, 'path' => $image->path, 'declaration' => 'ai_generated'],
]);
// Re-Submit nach Ablehnung erzeugt einen ZWEITEN Eintrag (Versionierung).
$pr->fresh()->update(['status' => PressReleaseStatus::Rejected->value]);
app(PressReleaseService::class)->submitForReview($pr->fresh());
expect(PressReleaseAiDeclaration::query()->count())->toBe(2);
});
test('the create editor requires the ai declaration only when submitting for review', function () {
/** @var TestCase $this */
[$customer] = makeAiDeclarationCustomer();
$category = Category::factory()->create();
$this->actingAs($customer);
$component = LivewireVolt::test('customer.press-releases.create')
->set('categoryId', $category->id)
->set('title', 'Ein gültiger Titel für die PM')
->set('text', str_repeat('Aussagekräftiger Fließtext. ', 5));
// Einreichen ohne Deklaration → Fehler im Modal, keine PM angelegt.
$component
->call('save', 'review')
->assertHasErrors(['aiTextDeclaration', 'aiDeclarationConfirmed']);
expect(PressRelease::query()->where('user_id', $customer->id)->count())->toBe(0);
// Entwurf-Speichern bleibt ohne Deklaration möglich.
$component
->call('save', 'draft')
->assertHasNoErrors();
$draft = PressRelease::query()->where('user_id', $customer->id)->sole();
expect($draft->ai_text_declaration)->toBeNull();
expect($draft->ai_declaration_confirmed_at)->toBeNull();
});
test('submitting from the create editor persists the declaration and the audit entry', function () {
/** @var TestCase $this */
[$customer] = makeAiDeclarationCustomer();
$category = Category::factory()->create();
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.create')
->set('categoryId', $category->id)
->set('title', 'Ein gültiger Titel für die PM')
->set('text', str_repeat('Aussagekräftiger Fließtext. ', 5))
->set('aiTextDeclaration', AiTextDeclaration::FullyAi->value)
->set('aiDeclarationConfirmed', true)
->call('save', 'review')
->assertHasNoErrors();
$pr = PressRelease::query()->where('user_id', $customer->id)->sole();
expect($pr->status)->toBe(PressReleaseStatus::Review);
expect($pr->ai_text_declaration)->toBe(AiTextDeclaration::FullyAi);
expect($pr->ai_declaration_confirmed_at)->not->toBeNull();
expect($pr->aiTextPublicLabel())
->toBe('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung vollständig mit generativer KI erstellt.');
$entry = PressReleaseAiDeclaration::query()->sole();
expect($entry->press_release_id)->toBe($pr->id);
expect($entry->text_declaration)->toBe(AiTextDeclaration::FullyAi);
});
test('submitting from the detail page validates and persists the declaration', function () {
/** @var TestCase $this */
[$customer, $company] = makeAiDeclarationCustomer();
$pr = PressRelease::factory()->aiUndeclared()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'portal' => $company->portal->value,
'status' => PressReleaseStatus::Draft->value,
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->call('submitForReview')
->assertHasErrors(['aiTextDeclaration', 'aiDeclarationConfirmed']);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->set('aiTextDeclaration', AiTextDeclaration::Assisted->value)
->set('aiDeclarationConfirmed', true)
->call('submitForReview')
->assertHasNoErrors();
$pr->refresh();
expect($pr->status)->toBe(PressReleaseStatus::Review);
expect($pr->ai_text_declaration)->toBe(AiTextDeclaration::Assisted);
// Formulierungshilfe = Entlastungsfall → kein öffentliches Pflicht-Label.
expect($pr->aiTextPublicLabel())->toBeNull();
expect(PressReleaseAiDeclaration::query()->count())->toBe(1);
});
test('the submit modal renders the declaration options and the binding confirmation', function () {
/** @var TestCase $this */
[$customer, $company] = makeAiDeclarationCustomer();
$pr = PressRelease::factory()->aiUndeclared()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'portal' => $company->portal->value,
'status' => PressReleaseStatus::Draft->value,
]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('KI-Kennzeichnung')
->assertSee('Ohne generative KI erstellt')
->assertSee('Vollständig KI-generiert (ohne wesentliche menschliche Bearbeitung)')
->assertSee('Ich bestätige, dass meine Angaben zum Einsatz generativer KI vollständig und wahrheitsgemäß sind.');
});
test('image uploads require the per-image ai declaration', function () {
/** @var TestCase $this */
[$customer, $company] = makeAiDeclarationCustomer();
$pr = PressRelease::factory()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'category_id' => Category::factory()->create()->id,
'portal' => $company->portal->value,
'status' => 'draft',
]);
Storage::fake('public');
$this->actingAs($customer);
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
->set('newCopyright', 'Foto: Jane Doe')
->set('newAuthor', 'Jane Doe')
->set('newLicenseType', ImageLicenseType::Own->value)
->set('newPeopleRightsStatus', 'none')
->set('newPropertyRightsStatus', 'none')
->set('newRightsConfirmed', true)
->call('saveImage')
->assertHasErrors(['newAiDeclaration']);
expect($pr->images()->count())->toBe(0);
});