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,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,