KI-Kennzeichnung: 2 Optionen im Submit-Modal + Labels auf Detailseite

Submit-Modal auf zwei Auswahloptionen reduziert (kein Label vs.
"Ganz oder ueberwiegend mit generativer KI erstellt"); alle vier
Enum-Werte bleiben fuer API/Bestand gueltig und mappen per formOption()
auf die zwei Gruppen. Oeffentliche Detailseite rendert jetzt Text-Label
("KI-generiert: Nach Angaben ...") und Bild-Badge (KI-generiert/
KI-bearbeitet, abgedunkelter Hintergrund) — fehlte dort bisher komplett.
Bildnachweis-Vorschlag ohne "Foto:"-Praefix (stand sonst doppelt).
Einreich-Toast + FAQ: automatische Pruefung in Minuten statt 24 h.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-07-10 16:51:23 +00:00
parent c8836564e9
commit dc2a3443c1
13 changed files with 172 additions and 50 deletions

View file

@ -28,15 +28,16 @@ enum AiImageDeclaration: string
} }
/** /**
* Öffentlicher Bildhinweis direkt am Bild — null bei Originalfotos * Öffentlicher Bildhinweis direkt am Bild (Badge) — null bei Originalfotos
* (keine Nicht-KI-Zertifizierung in Portalstimme). * (keine Nicht-KI-Zertifizierung in Portalstimme). Kurzform „KI-generiert"
* statt Satz-Wortlaut (User-Entscheidung 10.07.2026).
*/ */
public function publicLabel(): ?string public function publicLabel(): ?string
{ {
return match ($this) { return match ($this) {
self::Original => null, self::Original => null,
self::AiGenerated => __('Bildhinweis: KI-generierte Illustration.'), self::AiGenerated => __('KI-generiert'),
self::AiEdited => __('Bildhinweis: KI-bearbeitetes Bild.'), self::AiEdited => __('KI-bearbeitet'),
}; };
} }

View file

@ -5,10 +5,15 @@ namespace App\Enums;
/** /**
* Selbst-Deklaration des KI-Einsatzes beim PM-Text (Art. 50 Abs. 4 KI-VO). * 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): * Pflichtauswahl im Submit-Flow (Decision-Update KI-Kennzeichnung, 08.07.2026;
* gelabelt wird allein nach dem deklarierten KI-Grad, nicht nach der * vereinfacht auf zwei Auswahloptionen, 10.07.2026): gelabelt wird allein nach
* Public-Interest-Frage — bewusste Über-Erfüllung. Das öffentliche Label * dem deklarierten KI-Grad, nicht nach der Public-Interest-Frage — bewusste
* spricht in Einreicher-Stimme, nie in Portalstimme. * Über-Erfüllung. Das öffentliche Label spricht in Einreicher-Stimme, nie in
* Portalstimme.
*
* Alle vier Werte bleiben gültig (API-Vertrag v1 + Bestandsdaten); UI und
* Label-Logik fassen sie zu zwei Gruppen zusammen: None/Assisted = kein Label,
* MostlyAi/FullyAi = KI-Label.
*/ */
enum AiTextDeclaration: string enum AiTextDeclaration: string
{ {
@ -18,15 +23,13 @@ enum AiTextDeclaration: string
case FullyAi = 'fully_ai'; case FullyAi = 'fully_ai';
/** /**
* Wortlaut der Auswahloption im Submit-Flow. * Wortlaut der Auswahloption im Submit-Flow (zwei Gruppen).
*/ */
public function label(): string public function label(): string
{ {
return match ($this) { return match ($this) {
self::None => __('Ohne generative KI erstellt'), self::None, self::Assisted => __('Ohne generative KI erstellt – oder KI nur als Formulierungshilfe, mit anschließender menschlicher Überarbeitung und Verantwortung.'),
self::Assisted => __('Mit KI als Formulierungshilfe erstellt und redaktionell überarbeitet'), self::MostlyAi, self::FullyAi => __('Ganz oder überwiegend mit generativer KI erstellt.'),
self::MostlyAi => __('Überwiegend mit generativer KI erstellt und anschließend geprüft'),
self::FullyAi => __('Vollständig KI-generiert (ohne wesentliche menschliche Bearbeitung)'),
}; };
} }
@ -39,11 +42,32 @@ enum AiTextDeclaration: string
{ {
return match ($this) { return match ($this) {
self::None, self::Assisted => null, self::None, self::Assisted => null,
self::MostlyAi => __('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung überwiegend mit generativer KI erstellt.'), self::MostlyAi, self::FullyAi => __('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.'),
self::FullyAi => __('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung vollständig mit generativer KI erstellt.'),
}; };
} }
/**
* Kanonische Formular-Option der Gruppe — mappt Alt-/API-Werte
* (assisted, fully_ai) auf die zwei im Modal angebotenen Radios.
*/
public function formOption(): self
{
return match ($this) {
self::None, self::Assisted => self::None,
self::MostlyAi, self::FullyAi => self::MostlyAi,
};
}
/**
* Die zwei im Submit-Modal angebotenen Auswahloptionen.
*
* @return list<self>
*/
public static function formCases(): array
{
return [self::None, self::MostlyAi];
}
/** /**
* Optionsliste für Radios/Selects: value => label. * Optionsliste für Radios/Selects: value => label.
* *

View file

@ -224,7 +224,7 @@ return [
'faq_contact' => 'Direkt Kontakt aufnehmen', 'faq_contact' => 'Direkt Kontakt aufnehmen',
'faq_response_note' => 'Antwort werktags per E-Mail', 'faq_response_note' => 'Antwort werktags per E-Mail',
'faq_1_q' => 'Wie schnell wird meine Mitteilung veröffentlicht?', 'faq_1_q' => 'Wie schnell wird meine Mitteilung veröffentlicht?',
'faq_1_a' => 'Werktags üblicherweise innerhalb von 24 Stunden nach Einreichung. Bei zeitkritischen Mitteilungen ist eine Vorab-Absprache möglich.', 'faq_1_a' => 'Die automatische Inhaltsprüfung ist in der Regel innerhalb weniger Minuten abgeschlossen — Ihre Mitteilung erscheint direkt danach. Nur in Einzelfällen prüfen wir manuell nach, dann werktags üblicherweise innerhalb von 24 Stunden.',
'faq_2_q' => 'Bleibt meine Mitteilung dauerhaft online?', 'faq_2_q' => 'Bleibt meine Mitteilung dauerhaft online?',
'faq_2_a' => 'Ja. Mitteilungen werden nicht gelöscht. Korrekturen sind jederzeit möglich — die URL bleibt erhalten.', 'faq_2_a' => 'Ja. Mitteilungen werden nicht gelöscht. Korrekturen sind jederzeit möglich — die URL bleibt erhalten.',
'faq_3_q' => 'Kann ich auch auf presseecho.de veröffentlichen?', 'faq_3_q' => 'Kann ich auch auf presseecho.de veröffentlichen?',

View file

@ -223,7 +223,7 @@ return [
'faq_contact' => 'Get in touch directly', 'faq_contact' => 'Get in touch directly',
'faq_response_note' => 'Reply by email on business days', 'faq_response_note' => 'Reply by email on business days',
'faq_1_q' => 'How quickly will my release be published?', 'faq_1_q' => 'How quickly will my release be published?',
'faq_1_a' => 'On business days usually within 24 hours of submission. For time-critical releases, prior arrangement is possible.', 'faq_1_a' => 'The automated content review is usually completed within a few minutes — your release goes live right after. Only in exceptional cases do we review manually, typically within 24 hours on business days.',
'faq_2_q' => 'Does my release stay online permanently?', 'faq_2_q' => 'Does my release stay online permanently?',
'faq_2_a' => 'Yes. Releases are not deleted. Corrections are possible at any time — the URL is preserved.', 'faq_2_a' => 'Yes. Releases are not deleted. Corrections are possible at any time — the URL is preserved.',
'faq_3_q' => 'Can I also publish on presseecho.de?', 'faq_3_q' => 'Can I also publish on presseecho.de?',

View file

@ -126,14 +126,16 @@
<p class="m-0 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]"> <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).') }} {{ __('Wie wurde der Text dieser Pressemitteilung erstellt? Diese Angabe ist gesetzlich verpflichtend (Art. 50 KI-VO).') }}
</p> </p>
{{-- Zwei Gruppen (Entscheidung 10.07.2026): Option 1 = kein
Label, Option 2 = Hinweis „KI-generiert" am Pressetext. --}}
<flux:radio.group wire:model="aiTextDeclaration"> <flux:radio.group wire:model="aiTextDeclaration">
@foreach (\App\Enums\AiTextDeclaration::cases() as $aiOption) @foreach (\App\Enums\AiTextDeclaration::formCases() as $aiOption)
<flux:radio :value="$aiOption->value" :label="$aiOption->label()" /> <flux:radio :value="$aiOption->value" :label="$aiOption->label()" />
@endforeach @endforeach
</flux:radio.group> </flux:radio.group>
<flux:error name="aiTextDeclaration" /> <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)]"> <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.') }} {{ __('Bei der ersten Option erscheint kein Label an der Veröffentlichung. Bei der zweiten Option wird der Pressetext mit dem Hinweis „KI-generiert" in Ihrer Stimme gekennzeichnet („Nach Angaben des einreichenden Unternehmens …"). KI-generierte oder KI-bearbeitete Bilder kennzeichnen Sie direkt beim Bild-Upload.') }}
</p> </p>
</div> </div>
</article> </article>

View file

@ -198,13 +198,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@php @php
$adminTitleImage = $pr->images->sortByDesc('is_preview')->first(); $adminTitleImage = $pr->images->sortByDesc('is_preview')->first();
@endphp @endphp
@if ($adminTitleImage && ($adminTitleImage->copyright || $adminTitleImage->is_ai_generated)) @if ($adminTitleImage && ($adminTitleImage->copyright || $adminTitleImage->is_ai_generated || $adminTitleImage->ai_declaration?->publicLabel()))
{{-- Bildnachweis + KI-Kennzeichnung (Art. 50 EU AI Act) --}} {{-- Bildnachweis + KI-Kennzeichnung (Art. 50 EU AI Act) --}}
<div class="flex flex-wrap items-center gap-2 border-t border-[color:var(--color-bg-rule)] px-5 py-2.5 text-[12px] text-[color:var(--color-ink-3)]"> <div class="flex flex-wrap items-center gap-2 border-t border-[color:var(--color-bg-rule)] px-5 py-2.5 text-[12px] text-[color:var(--color-ink-3)]">
@if ($adminTitleImage->is_ai_generated) @if ($aiImageBadge = $adminTitleImage->ai_declaration?->publicLabel() ?? ($adminTitleImage->is_ai_generated ? __('KI-generiert') : null))
<span class="badge hub">{{ __('KI-generiert') }}</span> <span class="badge hub">{{ $aiImageBadge }}</span>
@endif
@if ($adminTitleImage->copyright)
<span>{{ $adminTitleImage->copyright }}</span>
@endif @endif
<span>{{ $adminTitleImage->copyright ?? __('Bild: KI-generiert') }}</span>
</div> </div>
@endif @endif
@endif @endif

View file

@ -101,7 +101,7 @@ new class extends Component {
$this->resetErrorBag('newLicenseDetail'); $this->resetErrorBag('newLicenseDetail');
// Automatisch erzeugte Vorschläge weichen dem Vorschlag des neuen Typs. // Automatisch erzeugte Vorschläge weichen dem Vorschlag des neuen Typs.
if ($this->newCopyright === $this->autoCopyright || str_starts_with($this->newCopyright, __('Bild: KI-generiert'))) { if ($this->newCopyright === $this->autoCopyright || str_starts_with($this->newCopyright, __('KI-generiert'))) {
$this->newCopyright = ''; $this->newCopyright = '';
$this->autoCopyright = ''; $this->autoCopyright = '';
} }
@ -135,7 +135,9 @@ new class extends Component {
return; return;
} }
$this->autoCopyright = filled($this->newAuthor) ? __('Foto: :author', ['author' => trim($this->newAuthor)]) : ''; // Ohne „Foto:"-Präfix — die Veröffentlichungsseite stellt es dem
// Bildnachweis bereits selbst voran (sonst stünde dort „Foto: Foto: …").
$this->autoCopyright = filled($this->newAuthor) ? trim($this->newAuthor) : '';
$this->newCopyright = $this->autoCopyright; $this->newCopyright = $this->autoCopyright;
} }
@ -154,11 +156,11 @@ new class extends Component {
return; return;
} }
if (filled($this->newCopyright) && !str_starts_with($this->newCopyright, __('Bild: KI-generiert'))) { if (filled($this->newCopyright) && !str_starts_with($this->newCopyright, __('KI-generiert'))) {
return; return;
} }
$this->newCopyright = filled($this->newLicenseDetail) ? __('Bild: KI-generiert (:tool)', ['tool' => trim($this->newLicenseDetail)]) : __('Bild: KI-generiert'); $this->newCopyright = filled($this->newLicenseDetail) ? __('KI-generiert (:tool)', ['tool' => trim($this->newLicenseDetail)]) : __('KI-generiert');
} }
public function closeUploadForm(): void public function closeUploadForm(): void
@ -214,7 +216,7 @@ new class extends Component {
]), ]),
], ],
[ [
'newCopyright.required' => __('Bitte einen öffentlichen Bildnachweis angeben, z. B. Foto: Max Mustermann / Beispiel GmbH.'), 'newCopyright.required' => __('Bitte einen öffentlichen Bildnachweis angeben, z. B. Max Mustermann / Beispiel GmbH.'),
'newAuthor.required' => $isAiGenerated ? __('Bitte angeben, wer für die Erstellung verantwortlich ist (Person oder Firma).') : __('Bitte Urheber, Fotograf oder Rechteinhaber angeben.'), 'newAuthor.required' => $isAiGenerated ? __('Bitte angeben, wer für die Erstellung verantwortlich ist (Person oder Firma).') : __('Bitte Urheber, Fotograf oder Rechteinhaber angeben.'),
'newLicenseType.required' => __('Bitte einen Lizenztyp wählen.'), 'newLicenseType.required' => __('Bitte einen Lizenztyp wählen.'),
'newLicenseDetail.required' => $isAiGenerated ? __('Bitte das verwendete KI-Tool angeben, z. B. Midjourney, Gemini, ChatGPT, DALL·E, Adobe Firefly.') : __('Bitte die Lizenz genauer angeben.'), 'newLicenseDetail.required' => $isAiGenerated ? __('Bitte das verwendete KI-Tool angeben, z. B. Midjourney, Gemini, ChatGPT, DALL·E, Adobe Firefly.') : __('Bitte die Lizenz genauer angeben.'),
@ -624,8 +626,8 @@ new class extends Component {
<flux:input wire:model="newCopyright" :label="__('Öffentlicher Bildnachweis')" <flux:input wire:model="newCopyright" :label="__('Öffentlicher Bildnachweis')"
:badge="__('Pflicht')" :badge="__('Pflicht')"
:description="__('Wird öffentlich unter dem Bild angezeigt — Vorschlag anpassbar.')" :description="__('Wird öffentlich unter dem Bild angezeigt („Foto: …") — Vorschlag anpassbar.')"
:placeholder="__('z. B. Foto: Max Mustermann / Beispiel GmbH')" required /> :placeholder="__('z. B. Max Mustermann / Beispiel GmbH')" required />
</section> </section>
{{-- ===== Schritt 3 · Personen & Rechte Dritter ===== --}} {{-- ===== Schritt 3 · Personen & Rechte Dritter ===== --}}

View file

@ -341,7 +341,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
Flux::toast( Flux::toast(
heading: __('Eingereicht'), heading: __('Eingereicht'),
text: __('Die Inhaltsprüfung ist typischerweise innerhalb von 24 h abgeschlossen.'), text: __('Die automatische Inhaltsprüfung ist in der Regel innerhalb weniger Minuten abgeschlossen.'),
variant: 'success', variant: 'success',
); );
} else { } else {

View file

@ -125,9 +125,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
$this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($pr->placeholder_variant?->value)->value; $this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($pr->placeholder_variant?->value)->value;
// KI-Grad vorbelegen (bereits deklariert); die verbindliche // KI-Grad vorbelegen (bereits deklariert), gemappt auf die zwei
// Bestätigung muss bei jedem Submit erneut gesetzt werden. // Formular-Optionen; die verbindliche Bestätigung muss bei jedem
$this->aiTextDeclaration = $pr->ai_text_declaration?->value; // Submit erneut gesetzt werden.
$this->aiTextDeclaration = $pr->ai_text_declaration?->formOption()->value;
} }
#[On('placeholder-selected')] #[On('placeholder-selected')]
@ -337,7 +338,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
Flux::toast( Flux::toast(
heading: __('Eingereicht'), heading: __('Eingereicht'),
text: __('Die Inhaltsprüfung ist typischerweise innerhalb von 24 h abgeschlossen.'), text: __('Die automatische Inhaltsprüfung ist in der Regel innerhalb weniger Minuten abgeschlossen.'),
variant: 'success', variant: 'success',
); );
} else { } else {

View file

@ -42,6 +42,9 @@
$imageUrl = $image?->variantUrl('detail') ?? $image?->variantUrl('card') ?? $image?->url(); $imageUrl = $image?->variantUrl('detail') ?? $image?->variantUrl('card') ?? $image?->url();
$imageCaption = $image?->description ?: $image?->title; $imageCaption = $image?->description ?: $image?->title;
$imageCredit = $image?->copyright ?: $image?->author; $imageCredit = $image?->copyright ?: $image?->author;
// KI-Bildkennzeichnung (Art. 50 KI-VO): Badge direkt am Bild
// („KI-generiert" / „KI-bearbeitet"), null bei Originalfotos.
$imageAiBadge = $image?->ai_declaration?->publicLabel();
// Ohne Pressefoto: die im User-Panel gewählte Platzhalter-Variante. // Ohne Pressefoto: die im User-Panel gewählte Platzhalter-Variante.
// Null bei Altbeständen ohne Variante — dann generischer Hero-Fallback. // Null bei Altbeständen ohne Variante — dann generischer Hero-Fallback.
@ -306,6 +309,13 @@
<div class="relative flex items-end p-[18px] bg-bg-dark overflow-hidden" style="aspect-ratio:21/9;"> <div class="relative flex items-end p-[18px] bg-bg-dark overflow-hidden" style="aspect-ratio:21/9;">
<img src="{{ $imageUrl }}" alt="{{ $imageCaption ?: $release->title }}" <img src="{{ $imageUrl }}" alt="{{ $imageCaption ?: $release->title }}"
class="absolute inset-0 h-full w-full object-cover" loading="eager"> class="absolute inset-0 h-full w-full object-cover" loading="eager">
@if ($imageAiBadge)
{{-- KI-Badge am Bild (Art. 50 KI-VO): kräftig abgedunkelter
Hintergrund, damit es auch auf hellen Bildern lesbar bleibt. --}}
<span class="relative text-[9.5px] font-bold tracking-[0.18em] uppercase text-white px-2.5 py-[3px] border border-white/60 font-mono bg-black/75 shadow-sm">
{{ $imageAiBadge }}
</span>
@endif
</div> </div>
@if ($imageCaption || $imageCredit) @if ($imageCaption || $imageCredit)
<div class="text-[11.5px] text-ink-3 mt-2.5 italic max-w-[720px]"> <div class="text-[11.5px] text-ink-3 mt-2.5 italic max-w-[720px]">
@ -319,6 +329,19 @@
<section class="max-w-layout mx-auto px-4 sm:px-6 lg:px-8 pt-10 grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-10 lg:gap-14"> <section class="max-w-layout mx-auto px-4 sm:px-6 lg:px-8 pt-10 grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-10 lg:gap-14">
{{-- Body --}} {{-- Body --}}
<div> <div>
{{-- KI-Label (Art. 50 KI-VO) in Einreicher-Stimme am Anfang
des Inhalts; neutrales ℹ-Icon, kein Flux auf Web-Domains. --}}
@if ($aiTextLabel = $release->aiTextPublicLabel())
<div class="flex items-start gap-2 border border-bg-rule-strong bg-bg-elev px-3 py-2.5 mb-6 max-w-[680px] text-[12px] leading-[1.5] text-ink-3">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true" class="shrink-0 mt-0.5">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8" fill="none" />
<path d="M12 11v5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
<circle cx="12" cy="8" r="1.1" fill="currentColor" />
</svg>
<span>{{ $aiTextLabel }}</span>
</div>
@endif
<div class="pm-body max-w-[680px]"> <div class="pm-body max-w-[680px]">
{!! $bodyHtml !!} {!! $bodyHtml !!}
</div> </div>

View file

@ -41,18 +41,29 @@ function makeAiDeclarationCustomer(): array
} }
test('the text declaration maps to the attributed public label per decision update', function () { test('the text declaration maps to the attributed public label per decision update', function () {
// Zwei Gruppen (Vereinfachung 10.07.2026): None/Assisted → kein Label,
// MostlyAi/FullyAi → gemeinsames „KI-generiert"-Label in Einreicher-Stimme.
expect(AiTextDeclaration::None->publicLabel())->toBeNull(); expect(AiTextDeclaration::None->publicLabel())->toBeNull();
expect(AiTextDeclaration::Assisted->publicLabel())->toBeNull(); expect(AiTextDeclaration::Assisted->publicLabel())->toBeNull();
expect(AiTextDeclaration::MostlyAi->publicLabel()) expect(AiTextDeclaration::MostlyAi->publicLabel())
->toBe('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung überwiegend mit generativer KI erstellt.'); ->toBe('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.');
expect(AiTextDeclaration::FullyAi->publicLabel()) expect(AiTextDeclaration::FullyAi->publicLabel())
->toBe('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung vollständig mit generativer KI erstellt.'); ->toBe('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.');
});
test('every stored value maps onto one of the two form options', function () {
expect(AiTextDeclaration::None->formOption())->toBe(AiTextDeclaration::None);
expect(AiTextDeclaration::Assisted->formOption())->toBe(AiTextDeclaration::None);
expect(AiTextDeclaration::MostlyAi->formOption())->toBe(AiTextDeclaration::MostlyAi);
expect(AiTextDeclaration::FullyAi->formOption())->toBe(AiTextDeclaration::MostlyAi);
expect(AiTextDeclaration::formCases())->toBe([AiTextDeclaration::None, AiTextDeclaration::MostlyAi]);
}); });
test('the image declaration maps to the public image hint per decision update', function () { test('the image declaration maps to the public image hint per decision update', function () {
// Kurzform als Badge direkt am Bild (User-Entscheidung 10.07.2026).
expect(AiImageDeclaration::Original->publicLabel())->toBeNull(); expect(AiImageDeclaration::Original->publicLabel())->toBeNull();
expect(AiImageDeclaration::AiGenerated->publicLabel())->toBe('Bildhinweis: KI-generierte Illustration.'); expect(AiImageDeclaration::AiGenerated->publicLabel())->toBe('KI-generiert');
expect(AiImageDeclaration::AiEdited->publicLabel())->toBe('Bildhinweis: KI-bearbeitetes Bild.'); expect(AiImageDeclaration::AiEdited->publicLabel())->toBe('KI-bearbeitet');
}); });
test('submit is blocked without a complete ai declaration', function () { test('submit is blocked without a complete ai declaration', function () {
@ -199,7 +210,7 @@ test('submitting from the create editor persists the declaration and the audit e
expect($pr->ai_text_declaration)->toBe(AiTextDeclaration::FullyAi); expect($pr->ai_text_declaration)->toBe(AiTextDeclaration::FullyAi);
expect($pr->ai_declaration_confirmed_at)->not->toBeNull(); expect($pr->ai_declaration_confirmed_at)->not->toBeNull();
expect($pr->aiTextPublicLabel()) expect($pr->aiTextPublicLabel())
->toBe('Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung vollständig mit generativer KI erstellt.'); ->toBe('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.');
$entry = PressReleaseAiDeclaration::query()->sole(); $entry = PressReleaseAiDeclaration::query()->sole();
expect($entry->press_release_id)->toBe($pr->id); expect($entry->press_release_id)->toBe($pr->id);
@ -254,8 +265,8 @@ test('the submit modal renders the declaration options and the binding confirmat
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('KI-Kennzeichnung') ->assertSee('KI-Kennzeichnung')
->assertSee('Ohne generative KI erstellt') ->assertSee('Ohne generative KI erstellt – oder KI nur als Formulierungshilfe, mit anschließender menschlicher Überarbeitung und Verantwortung.')
->assertSee('Vollständig KI-generiert (ohne wesentliche menschliche Bearbeitung)') ->assertSee('Ganz oder überwiegend mit generativer KI erstellt.')
->assertSee('Ich bestätige, dass meine Angaben zum Einsatz generativer KI vollständig und wahrheitsgemäß sind.'); ->assertSee('Ich bestätige, dass meine Angaben zum Einsatz generativer KI vollständig und wahrheitsgemäß sind.');
}); });

View file

@ -336,20 +336,21 @@ test('a valid ai generated upload stores tool and ai flag', function () {
expect($image->is_ai_generated)->toBeTrue(); expect($image->is_ai_generated)->toBeTrue();
// Lizenztyp „KI-generiert" setzt die KI-Kennzeichnung automatisch. // Lizenztyp „KI-generiert" setzt die KI-Kennzeichnung automatisch.
expect($image->ai_declaration)->toBe(AiImageDeclaration::AiGenerated); expect($image->ai_declaration)->toBe(AiImageDeclaration::AiGenerated);
expect($image->copyright)->toBe('Bild: KI-generiert (Midjourney v7)'); expect($image->copyright)->toBe('KI-generiert (Midjourney v7)');
}); });
test('the copyright suggestion follows the author but respects manual input', function () { test('the copyright suggestion follows the author but respects manual input', function () {
/** @var TestCase $this */ /** @var TestCase $this */
// Ohne „Foto:"-Präfix — den stellt die Veröffentlichungsseite selbst voran.
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner(); ['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
$this->actingAs($owner); $this->actingAs($owner);
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
->set('newLicenseType', ImageLicenseType::Own->value) ->set('newLicenseType', ImageLicenseType::Own->value)
->set('newAuthor', 'Jane Doe') ->set('newAuthor', 'Jane Doe')
->assertSet('newCopyright', 'Foto: Jane Doe') ->assertSet('newCopyright', 'Jane Doe')
->set('newAuthor', 'John Smith') ->set('newAuthor', 'John Smith')
->assertSet('newCopyright', 'Foto: John Smith') ->assertSet('newCopyright', 'John Smith')
->set('newCopyright', 'Eigener Nachweis') ->set('newCopyright', 'Eigener Nachweis')
->set('newAuthor', 'Jane Doe') ->set('newAuthor', 'Jane Doe')
->assertSet('newCopyright', 'Eigener Nachweis'); ->assertSet('newCopyright', 'Eigener Nachweis');
@ -363,11 +364,11 @@ test('switching to ai replaces an automatically suggested author copyright', fun
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
->set('newLicenseType', ImageLicenseType::Own->value) ->set('newLicenseType', ImageLicenseType::Own->value)
->set('newAuthor', 'Jane Doe') ->set('newAuthor', 'Jane Doe')
->assertSet('newCopyright', 'Foto: Jane Doe') ->assertSet('newCopyright', 'Jane Doe')
->set('newLicenseType', ImageLicenseType::AiGenerated->value) ->set('newLicenseType', ImageLicenseType::AiGenerated->value)
->assertSet('newCopyright', 'Bild: KI-generiert') ->assertSet('newCopyright', 'KI-generiert')
->set('newLicenseType', ImageLicenseType::Own->value) ->set('newLicenseType', ImageLicenseType::Own->value)
->assertSet('newCopyright', 'Foto: Jane Doe'); ->assertSet('newCopyright', 'Jane Doe');
}); });
test('the ai copyright suggestion follows the tool but respects manual input', function () { test('the ai copyright suggestion follows the tool but respects manual input', function () {
@ -377,9 +378,9 @@ test('the ai copyright suggestion follows the tool but respects manual input', f
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id]) LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
->set('newLicenseType', ImageLicenseType::AiGenerated->value) ->set('newLicenseType', ImageLicenseType::AiGenerated->value)
->assertSet('newCopyright', 'Bild: KI-generiert') ->assertSet('newCopyright', 'KI-generiert')
->set('newLicenseDetail', 'DALL·E 3') ->set('newLicenseDetail', 'DALL·E 3')
->assertSet('newCopyright', 'Bild: KI-generiert (DALL·E 3)') ->assertSet('newCopyright', 'KI-generiert (DALL·E 3)')
->set('newCopyright', 'Eigener Nachweis') ->set('newCopyright', 'Eigener Nachweis')
->set('newLicenseDetail', 'Midjourney v7') ->set('newLicenseDetail', 'Midjourney v7')
->assertSet('newCopyright', 'Eigener Nachweis'); ->assertSet('newCopyright', 'Eigener Nachweis');

View file

@ -1,5 +1,7 @@
<?php <?php
use App\Enums\AiImageDeclaration;
use App\Enums\AiTextDeclaration;
use App\Enums\CompanyType; use App\Enums\CompanyType;
use App\Enums\Portal; use App\Enums\Portal;
use App\Enums\PressReleasePlaceholder; use App\Enums\PressReleasePlaceholder;
@ -216,6 +218,59 @@ test('release detail attributes the company itself without a distribution agency
->assertDontSeeText('übermittelt von'); ->assertDontSeeText('übermittelt von');
}); });
test('release detail shows the ai label only for ai-generated declarations', function () {
/** @var TestCase $this */
// Art. 50 KI-VO: Option 2 (ganz/überwiegend KI) trägt das Label
// „KI-generiert" am Anfang des Inhalts, Option 1 bleibt ohne Label.
// KI-deklarierte Bilder tragen das Badge „KI-generiert" direkt am Bild.
[$category, $company] = detailCategoryAndCompany(Portal::Businessportal24);
$aiRelease = PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create([
'slug' => 'ki-generierte-meldung',
'ai_text_declaration' => AiTextDeclaration::MostlyAi->value,
'published_at' => now()->subDay(),
]);
$aiRelease->images()->create([
'disk' => 'public',
'path' => 'press-releases/'.$aiRelease->id.'/images/title.jpg',
'author' => 'Beispiel GmbH',
'license_type' => 'ai_generated',
'rights_confirmed_at' => now(),
'is_ai_generated' => true,
'ai_declaration' => AiImageDeclaration::AiGenerated->value,
'is_preview' => true,
'sort_order' => 1,
]);
$this->get('https://businessportal24.test/de/press-release/'.$aiRelease->slug)
->assertSuccessful()
->assertSeeText('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.')
// Badge am Bild — Kurzform, nicht mehr „KI-generierte Illustration".
->assertSeeText('KI-generiert')
->assertDontSeeText('KI-generierte Illustration');
$plainRelease = PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create([
'slug' => 'meldung-ohne-ki',
'ai_text_declaration' => AiTextDeclaration::None->value,
'published_at' => now()->subDay(),
]);
$this->get('https://businessportal24.test/de/press-release/'.$plainRelease->slug)
->assertSuccessful()
->assertDontSeeText('KI-generiert');
});
test('release detail counts a hit', function () { test('release detail counts a hit', function () {
/** @var TestCase $this */ /** @var TestCase $this */
[$category, $company] = detailCategoryAndCompany(Portal::Businessportal24); [$category, $company] = detailCategoryAndCompany(Portal::Businessportal24);