diff --git a/app/Enums/AiTextDeclaration.php b/app/Enums/AiTextDeclaration.php index 2f214ab0..f02a0c04 100644 --- a/app/Enums/AiTextDeclaration.php +++ b/app/Enums/AiTextDeclaration.php @@ -22,6 +22,14 @@ enum AiTextDeclaration: string case MostlyAi = 'mostly_ai'; case FullyAi = 'fully_ai'; + /** + * Keine Angabe: Einreichung über die bisherige Service-Schnittstelle, die + * das Feld nicht kennt (Rechtspaket 10/2026, RP-09). Kein Kunde hat + * „ohne KI“ erklärt, also wird auch nichts erklärt: kein öffentliches + * Label, keine Bestätigung, im Formular nicht wählbar. + */ + case NotDeclared = 'not_declared'; + /** * Wortlaut der Auswahloption im Submit-Flow (zwei Gruppen). */ @@ -30,6 +38,7 @@ enum AiTextDeclaration: string return match ($this) { self::None, self::Assisted => __('Ohne generative KI erstellt – oder KI nur als Formulierungshilfe, mit anschließender menschlicher Überarbeitung und Verantwortung.'), self::MostlyAi, self::FullyAi => __('Ganz oder überwiegend mit generativer KI erstellt.'), + self::NotDeclared => __('Keine Angabe (über die bisherige Schnittstelle eingereicht).'), }; } @@ -41,7 +50,7 @@ enum AiTextDeclaration: string public function publicLabel(): ?string { return match ($this) { - self::None, self::Assisted => null, + self::None, self::Assisted, self::NotDeclared => null, self::MostlyAi, self::FullyAi => __('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.'), }; } @@ -53,7 +62,7 @@ enum AiTextDeclaration: string public function formOption(): self { return match ($this) { - self::None, self::Assisted => self::None, + self::None, self::Assisted, self::NotDeclared => self::None, self::MostlyAi, self::FullyAi => self::MostlyAi, }; } @@ -78,6 +87,10 @@ enum AiTextDeclaration: string $options = []; foreach (self::cases() as $case) { + if ($case === self::NotDeclared) { + continue; + } + $options[$case->value] = $case->label(); } @@ -91,4 +104,15 @@ enum AiTextDeclaration: string { return array_column(self::cases(), 'value'); } + + /** + * Werte, die ein Einreicher selbst erklären kann — ohne `not_declared`, + * das nur die Alt-Schnittstelle setzt. + * + * @return list + */ + public static function declarableValues(): array + { + return array_values(array_filter(self::values(), fn (string $value): bool => $value !== self::NotDeclared->value)); + } } diff --git a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php index 7c534eb8..849e4d9a 100644 --- a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php +++ b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php @@ -102,9 +102,10 @@ class LegacyPressReleaseController extends Controller 'backlink_url' => $validated['backlink_url'] ?? null, 'keywords' => $validated['keywords'] ?? null, 'status' => PressReleaseStatus::Draft->value, - // Kompatibilitäts-Default (Entscheidung 10.07.): Alt-Clients - // kennen die KI-Kennzeichnung nicht. - 'ai_text_declaration' => AiTextDeclaration::None->value, + // Alt-Clients kennen die KI-Kennzeichnung nicht. Seit Rechtspaket + // 10/2026 (RP-09) wird dafür nichts mehr im Namen des Kunden + // erklärt: „keine Angabe“, kein Label, keine Bestätigung. + 'ai_text_declaration' => AiTextDeclaration::NotDeclared->value, ]); $pressRelease->uuid = (string) Str::uuid(); $pressRelease->user_id = $user->id; @@ -114,7 +115,6 @@ class LegacyPressReleaseController extends Controller $validated['portal'] ?? null, $company->portal === Portal::Both ? $user->portal : $company->portal, ); - $pressRelease->ai_declaration_confirmed_at = now(); $pressRelease->slug = $pressRelease->generateUniqueSlug($pressRelease->title, [ 'portal' => $pressRelease->portal, 'language' => $language, diff --git a/app/Http/Controllers/Api/V1/PressReleaseController.php b/app/Http/Controllers/Api/V1/PressReleaseController.php index 64cbf544..c22fcc71 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseController.php @@ -76,13 +76,13 @@ class PressReleaseController extends Controller // damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden. 'status' => PressReleaseStatus::Draft->value, // Legacy-/service-Clients kennen die KI-Kennzeichnung (Art. 50 - // KI-VO) nicht — Entscheidung 10.07.: fehlende Angabe gilt als - // „ohne generative KI erstellt"; das API-Kunden-Mailing (R6.4) - // weist darauf hin, dass die weitere Nutzung diese Erklärung - // darstellt. v1-Clients deklarieren explizit beim Submit. + // KI-VO) nicht. Rechtspaket 10/2026 (RP-09): eine fehlende Angabe + // wird als „keine Angabe“ gespeichert, nicht als Erklärung „ohne + // KI“ im Namen des Kunden. v1-Clients deklarieren explizit beim + // Submit. ...($this->isLegacyServiceRequest($request) ? [ - 'ai_text_declaration' => AiTextDeclaration::None->value, - 'ai_declaration_confirmed_at' => now(), + 'ai_text_declaration' => AiTextDeclaration::NotDeclared->value, + 'ai_declaration_confirmed_at' => null, ] : []), ]); $pressRelease->slug = $pressRelease->generateUniqueSlug($validated['title'], [ @@ -197,7 +197,7 @@ class PressReleaseController extends Controller // Kenntnisnahme der dauerhaften Archivierung (AGB § 13) — // Entscheidung 10.07.2026. $validated = $request->validate([ - 'ai_text_declaration' => ['required', Rule::enum(AiTextDeclaration::class)], + 'ai_text_declaration' => ['required', Rule::in(AiTextDeclaration::declarableValues())], ]); $pressRelease = $this->findOwnedPressRelease($pressRelease, $request); diff --git a/app/Livewire/Concerns/InteractsWithAiDeclaration.php b/app/Livewire/Concerns/InteractsWithAiDeclaration.php index 610d6951..68d83a46 100644 --- a/app/Livewire/Concerns/InteractsWithAiDeclaration.php +++ b/app/Livewire/Concerns/InteractsWithAiDeclaration.php @@ -116,7 +116,7 @@ trait InteractsWithAiDeclaration protected function validateAiDeclaration(): void { $rules = [ - 'aiTextDeclaration' => ['required', Rule::in(AiTextDeclaration::values())], + 'aiTextDeclaration' => ['required', Rule::in(AiTextDeclaration::declarableValues())], 'aiDeclarationConfirmed' => ['accepted'], ]; $messages = [ diff --git a/app/Models/PressReleaseAiDeclaration.php b/app/Models/PressReleaseAiDeclaration.php index f5e6253f..cc17e7ab 100644 --- a/app/Models/PressReleaseAiDeclaration.php +++ b/app/Models/PressReleaseAiDeclaration.php @@ -13,6 +13,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * 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. + * + * „Keine Angabe“ über die Alt-Schnittstelle (`not_declared`, Rechtspaket + * 10/2026) wird ebenfalls festgehalten, dann ohne `confirmed_at`: Es wurde + * nichts erklärt, also auch nichts bestätigt. */ class PressReleaseAiDeclaration extends Model { diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index 047f2bf7..2ee14998 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -3,6 +3,7 @@ namespace App\Services\PressRelease; use App\Enums\ActivityType; +use App\Enums\AiTextDeclaration; use App\Enums\ArchiveEvent; use App\Enums\ArchiveReasonCategory; use App\Enums\EmailNotificationType; @@ -1012,7 +1013,15 @@ class PressReleaseService */ private function assertAiDeclarationComplete(PressRelease $pressRelease): void { - if ($pressRelease->ai_text_declaration === null || $pressRelease->ai_declaration_confirmed_at === null) { + if ($pressRelease->ai_text_declaration === null) { + throw new AiDeclarationRequiredException; + } + + // „Keine Angabe“ (Alt-Schnittstelle, RP-09) ist bewusst ohne + // Bestätigung: Es gibt nichts zu bestätigen. Alle anderen Werte + // brauchen die verbindliche Bestätigung des Einreichers. + if ($pressRelease->ai_text_declaration !== AiTextDeclaration::NotDeclared + && $pressRelease->ai_declaration_confirmed_at === null) { throw new AiDeclarationRequiredException; } diff --git a/database/migrations/2026_10_15_144449_make_confirmed_at_nullable_on_press_release_ai_declarations.php b/database/migrations/2026_10_15_144449_make_confirmed_at_nullable_on_press_release_ai_declarations.php new file mode 100644 index 00000000..0f93f665 --- /dev/null +++ b/database/migrations/2026_10_15_144449_make_confirmed_at_nullable_on_press_release_ai_declarations.php @@ -0,0 +1,29 @@ +timestamp('confirmed_at')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('press_release_ai_declarations', function (Blueprint $table): void { + $table->timestamp('confirmed_at')->nullable(false)->change(); + }); + } +}; diff --git a/resources/views/components/press-release-submit-modal.blade.php b/resources/views/components/press-release-submit-modal.blade.php index 8c966dc7..5f8c62e1 100644 --- a/resources/views/components/press-release-submit-modal.blade.php +++ b/resources/views/components/press-release-submit-modal.blade.php @@ -110,7 +110,7 @@
  1. {{ __('Der Text geht zur automatischen Inhaltsprüfung — er ist damit noch nicht online.') }}
  2. {{ __('Grün oder Gelb: die Mitteilung wird veröffentlicht. Rot: sie kommt mit Begründung zurück, Sie können nachbessern und erneut freigeben.') }}
  3. -
  4. {{ __('Nach :max Prüfungen mit Rot sieht sich die Redaktion den Text an und entscheidet.', ['max' => $maxAttempts]) }}
  5. +
  6. {{ __('Nach :max Prüfungen mit Rot sieht sich ein Mensch den Text an und entscheidet. Eine menschliche Überprüfung können Sie aber schon nach der ersten Ablehnung verlangen, formlos über Ihr Konto oder per E-Mail.', ['max' => $maxAttempts]) }}
@@ -129,7 +129,7 @@
  • {{ __('Aussagen entsprechen Ihrem Wissensstand und sind sachlich richtig.') }}
  • - {{ __('Sie stellen die Plattform von Ansprüchen Dritter frei, die aus einer unberechtigten Nutzung von Inhalten resultieren. Die endgültige Veröffentlichung erfolgt nach Abschluss der Inhaltsprüfung.') }} + {{ __('Für von Ihnen zu vertretende Pflichtverletzungen gilt die Freistellungsregelung der für diesen Auftrag vereinbarten AGB. Die endgültige Veröffentlichung erfolgt nach Abschluss der Inhaltsprüfung.') }}

    @@ -186,7 +186,7 @@

    - {{ __('Wie wurde der Text dieser Pressemitteilung erstellt? Diese Angabe ist gesetzlich verpflichtend (Art. 50 KI-VO).') }} + {{ __('Wie wurde der Text dieser Pressemitteilung erstellt? Wir benötigen diese Angabe nach unseren Veröffentlichungsbedingungen. Gesetzliche Kennzeichnungspflichten hängen vom Inhalt und der Art des KI-Einsatzes ab.') }}

    {{-- Zwei Gruppen (Entscheidung 10.07.2026): Option 1 = kein Label, Option 2 = Hinweis „KI-generiert" am Pressetext. --}} diff --git a/tests/Feature/Api/LegacyApiCompatibilityTest.php b/tests/Feature/Api/LegacyApiCompatibilityTest.php index cde6122b..19b2b23c 100644 --- a/tests/Feature/Api/LegacyApiCompatibilityTest.php +++ b/tests/Feature/Api/LegacyApiCompatibilityTest.php @@ -241,8 +241,8 @@ test('legacy service calls default the ai declaration instead of breaking old cl ], ['X-ApiKey' => 'legacy-key-abc123'])->assertCreated(); $pressRelease = PressRelease::withoutGlobalScopes()->find((int) basename((string) $response->headers->get('Location'))); - expect($pressRelease->ai_text_declaration?->value)->toBe('none') - ->and($pressRelease->ai_declaration_confirmed_at)->not->toBeNull(); + expect($pressRelease->ai_text_declaration?->value)->toBe('not_declared') + ->and($pressRelease->ai_declaration_confirmed_at)->toBeNull(); // Bild-Upload ohne KI-Feld: Default Originalfoto. $this->postJson("/service/pressreleases/{$pressRelease->id}/images", [ diff --git a/tests/Feature/Api/LegacyServiceParityTest.php b/tests/Feature/Api/LegacyServiceParityTest.php index e1149a25..c143087f 100644 --- a/tests/Feature/Api/LegacyServiceParityTest.php +++ b/tests/Feature/Api/LegacyServiceParityTest.php @@ -105,8 +105,8 @@ test('the full connektar flow works like the old api with immediate publishing', expect($pressRelease->status)->toBe(PressReleaseStatus::Published) ->and($pressRelease->published_at)->not->toBeNull() ->and($pressRelease->category_id)->toBe($category->id) - ->and($pressRelease->ai_text_declaration?->value)->toBe('none') - ->and($pressRelease->ai_declaration_confirmed_at)->not->toBeNull(); + ->and($pressRelease->ai_text_declaration?->value)->toBe('not_declared') + ->and($pressRelease->ai_declaration_confirmed_at)->toBeNull(); // 4. Bild hochladen — verschachtelt, KI-Default Originalfoto. $this->postJson("/service/pressreleases/{$pressRelease->id}/images", [ diff --git a/tests/Feature/PressReleaseAiDeclarationTest.php b/tests/Feature/PressReleaseAiDeclarationTest.php index 9d3de6d6..20400de2 100644 --- a/tests/Feature/PressReleaseAiDeclarationTest.php +++ b/tests/Feature/PressReleaseAiDeclarationTest.php @@ -84,6 +84,72 @@ test('submit is blocked without a complete ai declaration', function () { expect(PressReleaseAiDeclaration::query()->count())->toBe(0); }); +test('a legacy submission with "not declared" passes without a confirmation and gets no public label', function () { + /** @var TestCase $this */ + // Rechtspaket 10/2026 (RP-09): Die Alt-Schnittstelle erklärt nichts mehr im + // Namen des Kunden. „Keine Angabe" ist ohne Bestätigung gültig, trägt kein + // öffentliches Label und landet so im Audit-Trail. + [$customer, $company] = makeAiDeclarationCustomer(); + + $pr = PressRelease::factory()->create([ + 'user_id' => $customer->id, + 'company_id' => $company->id, + 'status' => PressReleaseStatus::Draft->value, + 'ai_text_declaration' => AiTextDeclaration::NotDeclared->value, + 'ai_declaration_confirmed_at' => null, + ]); + + $this->actingAs($customer); + + app(PressReleaseService::class)->submitForReview($pr, 'api'); + + $pr->refresh(); + expect($pr->status)->toBe(PressReleaseStatus::Review); + expect($pr->ai_text_declaration)->toBe(AiTextDeclaration::NotDeclared); + expect($pr->ai_declaration_confirmed_at)->toBeNull(); + expect($pr->aiTextPublicLabel())->toBeNull(); + + $entry = PressReleaseAiDeclaration::query()->sole(); + expect($entry->text_declaration)->toBe(AiTextDeclaration::NotDeclared); + expect($entry->confirmed_at)->toBeNull(); +}); + +test('"not declared" is not selectable in the editor nor via the v1 api', function () { + /** @var TestCase $this */ + [$customer, $company] = makeAiDeclarationCustomer(); + $category = Category::factory()->create(); + + expect(AiTextDeclaration::declarableValues())->not->toContain('not_declared'); + expect(array_keys(AiTextDeclaration::options()))->not->toContain('not_declared'); + expect(AiTextDeclaration::NotDeclared->formOption())->toBe(AiTextDeclaration::None); + + $this->actingAs($customer); + + Livewire::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::NotDeclared->value) + ->set('aiDeclarationConfirmed', true) + ->call('save', 'review') + ->assertHasErrors(['aiTextDeclaration']); + + $pr = PressRelease::factory()->aiUndeclared()->create([ + 'user_id' => $customer->id, + 'company_id' => $company->id, + 'portal' => $company->portal->value, + 'status' => PressReleaseStatus::Draft->value, + ]); + + apiActingAs($customer, ['press-releases:write']); + + $this->postJson("/api/v1/press-releases/{$pr->id}/submit", ['ai_text_declaration' => 'not_declared']) + ->assertUnprocessable() + ->assertInvalid(['ai_text_declaration']); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); +}); + test('submit is blocked while any image lacks its ai declaration', function () { /** @var TestCase $this */ [$customer] = makeAiDeclarationCustomer();