api: Alt-Schnittstelle speichert „keine Angabe“ statt „ohne KI“, Einreich-Dialog nennt Mensch und AGB
RP-09: Meldungen über /service ohne KI-Feld bekommen AiTextDeclaration NotDeclared — kein öffentliches Label, keine Bestätigung im Namen des Kunden, im Editor und in API v1 nicht wählbar (declarableValues()). Der Audit-Trail hält „nicht erklärt“ fest; dafür wird press_release_ai_declarations.confirmed_at nullable (Migration). Der Einreich-Dialog sagt, dass eine menschliche Prüfung schon nach der ersten Ablehnung verlangt werden kann, verweist bei der Freistellung auf die AGB und formuliert die KI-Angabe ohne Gesetzesbehauptung. Changelog: Verbessert Public: Über die bisherige Schnittstelle eingereichte Meldungen erhalten keine KI-Erklärung mehr im Namen des Kunden; der Einreich-Dialog nennt, dass eine menschliche Prüfung schon nach der ersten Ablehnung möglich ist. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
6c504262e6
commit
95b7b0d062
11 changed files with 154 additions and 22 deletions
|
|
@ -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<string>
|
||||
*/
|
||||
public static function declarableValues(): array
|
||||
{
|
||||
return array_values(array_filter(self::values(), fn (string $value): bool => $value !== self::NotDeclared->value));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Rechtspaket 10/2026 (RP-09): Meldungen über die Alt-Schnittstelle tragen
|
||||
* „keine Angabe“ (`not_declared`) — es gibt nichts zu bestätigen, also auch
|
||||
* keinen Bestätigungszeitpunkt. Der Audit-Eintrag bleibt trotzdem erhalten,
|
||||
* damit der Nachweis „nicht erklärt“ genauso belastbar ist wie „erklärt“.
|
||||
* Kleine Tabelle, kein FULLTEXT-Index: der Umbau dauert Sekunden.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('press_release_ai_declarations', function (Blueprint $table): void {
|
||||
$table->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -110,7 +110,7 @@
|
|||
<ol class="m-0 list-decimal space-y-1 ps-5">
|
||||
<li>{{ __('Der Text geht zur automatischen Inhaltsprüfung — er ist damit noch nicht online.') }}</li>
|
||||
<li>{{ __('Grün oder Gelb: die Mitteilung wird veröffentlicht. Rot: sie kommt mit Begründung zurück, Sie können nachbessern und erneut freigeben.') }}</li>
|
||||
<li>{{ __('Nach :max Prüfungen mit Rot sieht sich die Redaktion den Text an und entscheidet.', ['max' => $maxAttempts]) }}</li>
|
||||
<li>{{ __('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]) }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -129,7 +129,7 @@
|
|||
<li>{{ __('Aussagen entsprechen Ihrem Wissensstand und sind sachlich richtig.') }}</li>
|
||||
</ul>
|
||||
<p class="m-0 mt-3 pt-3 border-t border-[color:var(--color-bg-rule)] text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('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.') }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -186,7 +186,7 @@
|
|||
</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).') }}
|
||||
{{ __('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.') }}
|
||||
</p>
|
||||
{{-- Zwei Gruppen (Entscheidung 10.07.2026): Option 1 = kein
|
||||
Label, Option 2 = Hinweis „KI-generiert" am Pressetext. --}}
|
||||
|
|
|
|||
|
|
@ -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", [
|
||||
|
|
|
|||
|
|
@ -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", [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue