images: KI-Vorab-Check für Titelbilder — Vision-Sichtung mit definiertem Ausgang je Befund, Admin-Queue, Protokoll und Archiv-Snapshot (Bildkennzeichnung Block K)

Decision-Update Abschnitt 12 (E15, S24): Sichtung auf offensichtliche
Probleme, keine Rechteprüfung, öffentlich weiterhin „automatisierte Prüfung".

- ImagePrecheckManager mit openai- und deterministic-Treiber (Muster der
  Text-Prüfstrecke), Bild auf 1024 px, Metadaten gestrippt, JPEG als Data-URI,
  Antwortschema 12.4; Fehler → Ausgang `error`, durchlassen mit Vermerk.
- Auswertung 12.3: reject (Stock-Wasserzeichen, Nacktheit/Gewalt, Dokument
  ab 0,85), queue (Screenshot, fremdes Logo, Kennzeichen ab 0,6 sowie
  Ablehnungskategorien 0,6–0,85), hint (Personen, „wirkt KI" nur bei „ohne
  KI", technischer Mangel). Stufe 1 wertet nur das Wasserzeichen; die KI setzt
  nie die Kennzeichnung (E9). Herabstufung per Config (12.8).
- Uploader: Check vor jedem Schreiben, Ablehnung speichert nichts und
  landet als ki_audits `image_precheck` an der PM; Hash-Bindung zwischen
  Vorprüfung im Formular und Speichern; Spalten precheck*, Snapshot
  `precheck_snapshot` im Bestätigungsprotokoll, Archiv-Payload erweitert.
- Queue: Bild zurückgehalten (withheld_at), Meldung läuft weiter; Admin
  entscheidet mit Vermerk (Freigabe/Ablehnung → ki_audits
  `image_precheck_review`); Ansicht „Bild-Sichtung" in der PM-Liste,
  Sichtungsblock auf der PM-Seite, Systemstatus-Zähler.
- Formular v2: Ablehnungsgrund unter dem Dateifeld, Hinweise nach der
  Dateiauswahl, „wird kurz gesichtet" an der Karte; API 422 mit Grund,
  Warnung `image_under_review`.
- Config images.precheck (IMAGE_PRECHECK aus, STAGE 1); 17 Tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 13:27:31 +00:00
parent 3b613fadf4
commit 0e5cd20626
26 changed files with 1738 additions and 10 deletions

View file

@ -8,6 +8,8 @@ use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\Image\ImageRightsTextRenderer;
use App\Services\Image\ImageService;
use App\Services\Image\Precheck\ImagePrecheckResult;
use App\Services\Image\Precheck\ImagePrecheckService;
use App\Services\PressRelease\Images\CustomerImageConfirmation;
use App\Services\PressRelease\Images\ImageSubmission;
use App\Services\PressRelease\Images\ImageUploadActor;
@ -79,6 +81,10 @@ new class extends Component {
#[Locked]
public bool $enhancementHint = false;
/** Ergebnis des KI-Vorab-Checks (Block K) mit Hash-Bindung; null = Check aus. */
#[Locked]
public ?array $precheck = null;
public function mount(int $pressReleaseId): void
{
$this->pressReleaseId = $pressReleaseId;
@ -128,6 +134,18 @@ new class extends Component {
$this->aiSuggestion = $result->suggestedAiDeclaration()?->value;
$this->enhancementHint = (bool) $result->derived['enhancement_hint'];
// Vorab-Check (K.3): Ablehnung zeigt den Grund unter dem Dateifeld,
// alles andere bleibt als Hinweis oder wird beim Speichern übernommen.
try {
$this->precheck = $uploader->precheck($this->newImage, $this->getPressRelease())?->toArray();
} catch (ImageUploadException $exception) {
$this->addError('newImage', $exception->getMessage());
$this->resetProvenance();
$this->reset('newImage');
return;
}
// Nachweis vorbelegen: aus den Metadaten, sonst Firmenname — nur,
// solange der Nutzer nichts Eigenes eingetragen hat.
$suggested = $this->creditSuggestion ?? $this->companyName();
@ -239,6 +257,7 @@ new class extends Component {
$actor,
expectedSha256: $this->provenanceSha,
agbUrl: $this->agbUrl(),
precheck: $this->precheck !== null ? ImagePrecheckResult::fromArray($this->precheck) : null,
);
} catch (ImageUploadException $exception) {
$this->addError($exception->field === 'rights_confirmed' ? 'newRightsConfirmed' : 'newImage', $exception->getMessage());
@ -318,6 +337,8 @@ new class extends Component {
'titleImage' => $titleImage,
'latestConfirmation' => $titleImage?->latestRightsConfirmation,
'awaitsCustomerConfirmation' => $titleImage?->awaitsCustomerConfirmation() ?? false,
'awaitsPrecheckReview' => $titleImage?->awaitsPrecheckReview() ?? false,
'precheckHints' => $this->precheckHints(),
'canEdit' => auth()->user()?->can('update', $pressRelease) === true && $this->canChangeImages($pressRelease),
'isAdminActor' => $this->actsAsAdmin(),
'companyName' => $this->companyName(),
@ -443,8 +464,32 @@ new class extends Component {
return $pressRelease->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first();
}
/**
* Hinweise aus dem Vorab-Check (DU 12.3), abhängig von der aktuellen
* KI-Angabe: „wirkt KI-generiert" erscheint nur bei „ohne KI".
*
* @return array<int, string>
*/
private function precheckHints(): array
{
if ($this->precheck === null) {
return [];
}
$result = ImagePrecheckResult::fromArray($this->precheck);
if ($result === null) {
return [];
}
$choice = $this->newAiDeclaration !== '' ? AiImageDeclaration::tryFrom($this->newAiDeclaration) : null;
return app(ImagePrecheckService::class)->evaluate($result, $choice)->hints();
}
private function resetProvenance(): void
{
$this->precheck = null;
$this->provenanceStatus = null;
$this->provenanceSha = null;
$this->creditSuggestion = null;
@ -462,8 +507,8 @@ new class extends Component {
<flux:card>
<div class="flex items-center justify-between">
<flux:heading size="md">{{ __('Titelbild') }}</flux:heading>
<flux:badge color="{{ $titleImage ? ($awaitsCustomerConfirmation ? 'amber' : 'green') : 'zinc' }}" size="sm">
{{ $titleImage ? ($awaitsCustomerConfirmation ? __('wartet auf Kundenbestätigung') : __('gesetzt')) : __('Platzhalter aktiv') }}
<flux:badge color="{{ $titleImage ? (($awaitsCustomerConfirmation || $awaitsPrecheckReview) ? 'amber' : 'green') : 'zinc' }}" size="sm">
{{ $titleImage ? ($awaitsCustomerConfirmation ? __('wartet auf Kundenbestätigung') : ($awaitsPrecheckReview ? __('wird kurz gesichtet') : __('gesetzt'))) : __('Platzhalter aktiv') }}
</flux:badge>
</div>
@ -530,6 +575,12 @@ new class extends Component {
@endif
</div>
@if ($awaitsPrecheckReview)
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100" data-precheck-review>
{{ __('Wird kurz gesichtet. Ihre Meldung läuft normal weiter; das Bild erscheint, sobald die Sichtung abgeschlossen ist.') }}
</div>
@endif
@if ($awaitsCustomerConfirmation)
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('Dieses Bild wurde im Auftrag hochgeladen. Es wird erst veröffentlicht, wenn der Kontoinhaber die Bildrechte bestätigt hat.') }}
@ -624,6 +675,12 @@ new class extends Component {
</div>
@endif
@foreach ($precheckHints as $precheckHint)
<div class="rounded-md border border-sky-200 bg-sky-50 p-3 text-xs leading-relaxed text-sky-900 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-100" data-precheck-hint>
{{ $precheckHint }}
</div>
@endforeach
<div class="grid gap-3 sm:grid-cols-2">
<flux:select wire:model.live="focus" :label="__('Bildausschnitt: wichtiger Bereich')"
:description="__('Der Zuschnitt für Vorschauen hält diesen Bereich im Bild.')">
@ -652,7 +709,7 @@ new class extends Component {
: __('Vorschlag: Ihr Firmenname — bitte prüfen. Bei Stock- oder Agenturbildern den Nachweis so eintragen, wie die Lizenz ihn verlangt.')"
:placeholder="$companyName ?: __('z. B. Max Mustermann / Beispiel GmbH')" required />
<flux:radio.group wire:model="newAiDeclaration" :label="__('KI-Kennzeichnung')" :badge="__('Pflicht')"
<flux:radio.group wire:model.live="newAiDeclaration" :label="__('KI-Kennzeichnung')" :badge="__('Pflicht')"
:description="__('KI-generierte oder KI-bearbeitete Bilder erhalten öffentlich einen neutralen Hinweis. Übliche Bearbeitungen wie Zuschnitt, Farbkorrektur oder Schärfung gelten nicht als KI-Bearbeitung.')"
required>
@foreach ($aiOptions as $aiImageOption)