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 70e15d5df5
commit 8bb7049bdc
26 changed files with 1738 additions and 10 deletions

View file

@ -309,7 +309,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
})
// „Prüfung nötig" (E3): geblockt in review, Automatik erschöpft.
->when($this->statusFilter === 'manual', fn ($q) => $q->where('status', PressReleaseStatus::Review->value)->whereNotNull('manual_review_required_at'))
->when($this->statusFilter !== 'all' && $this->statusFilter !== 'manual', fn ($q) => $q->where('status', $this->statusFilter))
// Bild-Sichtung (Block K): Titelbild wartet auf eine menschliche Entscheidung.
->when($this->statusFilter === 'image_review', fn ($q) => $q->whereHas('images', fn ($imageQuery) => $imageQuery->awaitingPrecheckReview()))
->when(! in_array($this->statusFilter, ['all', 'manual', 'image_review'], true), fn ($q) => $q->where('status', $this->statusFilter))
->when($this->classificationFilter !== 'all', fn ($q) => $q->where('classification', $this->classificationFilter))
->when($this->portalFilter !== 'all', fn ($q) => $q->where('portal', $this->portalFilter))
->when($this->languageFilter !== 'all', fn ($q) => $q->where('language', $this->languageFilter))
@ -402,7 +404,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
}
/**
* @return array{total: int, published: int, review: int, draft: int, rejected: int, archived: int}
* @return array{total: int, published: int, review: int, draft: int, rejected: int, archived: int, manual: int, image_review: int}
*/
private function pressReleaseStats(): array
{
@ -426,6 +428,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
'rejected' => (int) ($stats->rejected ?? 0),
'archived' => (int) ($stats->archived ?? 0),
'manual' => (int) ($stats->manual ?? 0),
'image_review' => \App\Models\PressReleaseImage::query()->awaitingPrecheckReview()->count(),
];
});
}
@ -583,6 +586,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
@class(['view-tab', 'is-active' => $statusFilter === 'manual'])>
{{ __('Prüfung nötig') }} <span @class(['cnt', 'text-[color:var(--color-err)]' => ($stats['manual'] ?? 0) > 0])>{{ number_format($stats['manual'] ?? 0) }}</span>
</button>
{{-- Block K: Titelbild aus dem Vorab-Check wartet auf Sichtung. --}}
<button type="button" wire:click="setView('image_review')"
@class(['view-tab', 'is-active' => $statusFilter === 'image_review'])>
{{ __('Bild-Sichtung') }} <span @class(['cnt', 'text-[color:var(--color-err)]' => ($stats['image_review'] ?? 0) > 0])>{{ number_format($stats['image_review'] ?? 0) }}</span>
</button>
<button type="button" wire:click="setView('{{ \App\Enums\PressReleaseStatus::Published->value }}')"
@class(['view-tab', 'is-active' => $statusFilter === \App\Enums\PressReleaseStatus::Published->value])>
{{ __('Veröffentlicht') }} <span class="cnt">{{ number_format($stats['published']) }}</span>
@ -621,6 +629,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
<option value="{{ $s->value }}">{{ $s->label() }}</option>
@endforeach
<option value="manual">{{ __('Prüfung nötig (Automatik erschöpft)') }}</option>
<option value="image_review">{{ __('Bild-Sichtung (Vorab-Check)') }}</option>
</flux:select>
<flux:select wire:model.live="classificationFilter" class="w-full">
@ -824,10 +833,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
@endif
@if ($statusFilter !== 'all')
@php $statusEnum = $statusFilter === 'manual' ? null : \App\Enums\PressReleaseStatus::tryFrom($statusFilter); @endphp
@php $statusEnum = in_array($statusFilter, ['manual', 'image_review'], true) ? null : \App\Enums\PressReleaseStatus::tryFrom($statusFilter); @endphp
<span class="active-chip">
<span>{{ __('Status') }}:
<strong>{{ $statusEnum?->label() ?? ($statusFilter === 'manual' ? __('Prüfung nötig') : $statusFilter) }}</strong></span>
<strong>{{ $statusEnum?->label() ?? match ($statusFilter) { 'manual' => __('Prüfung nötig'), 'image_review' => __('Bild-Sichtung'), default => $statusFilter } }}</strong></span>
<button type="button" class="x" wire:click="setView('all')"
aria-label="{{ __('Filter entfernen') }}">
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">

View file

@ -5,6 +5,8 @@ use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Enums\AiImageDeclaration;
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
use App\Enums\ImagePrecheckCategory;
use App\Services\Image\Precheck\ImagePrecheckReviewService;
use App\Services\PressRelease\Images\ImageCorrectionService;
use App\Services\PressRelease\PressReleaseService;
use Flux\Flux;
@ -125,6 +127,47 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
);
}
/**
* Bild-Sichtung (Bildkennzeichnung Block K, DU 12.3): Ausgang `queue`
* des Vorab-Checks braucht eine menschliche Entscheidung mit Vermerk.
*/
public string $precheckNote = '';
public function approvePrecheck(ImagePrecheckReviewService $reviews): void
{
$this->decidePrecheck($reviews, true);
}
public function rejectPrecheck(ImagePrecheckReviewService $reviews): void
{
$this->decidePrecheck($reviews, false);
}
private function decidePrecheck(ImagePrecheckReviewService $reviews, bool $approve): void
{
$pr = $this->findAdminPR($this->id);
$image = $pr->images()->awaitingPrecheckReview()->orderByDesc('is_preview')->orderBy('sort_order')->first();
if ($image === null) {
Flux::toast(text: __('Kein Bild wartet auf eine Sichtung.'), variant: 'warning');
return;
}
$this->validate(
['precheckNote' => ['required', 'string', 'min:5', 'max:1000']],
['precheckNote.required' => __('Bitte geben Sie einen Vermerk zur Entscheidung an.')],
);
$approve
? $reviews->approve($image, auth()->user(), trim($this->precheckNote))
: $reviews->reject($image, auth()->user(), trim($this->precheckNote));
$this->reset(['precheckNote']);
Flux::toast(text: $approve ? __('Bild freigegeben und sichtbar.') : __('Bild abgelehnt und entfernt.'), variant: 'success');
}
public function toggleWithheld(ImageCorrectionService $corrections): void
{
$pr = $this->findAdminPR($this->id);
@ -317,6 +360,44 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@endif
</article>
{{-- ============== BILD-SICHTUNG (BLOCK K) ============== --}}
@if (isset($adminTitleImage) && $adminTitleImage && $adminTitleImage->awaitsPrecheckReview())
<article class="panel" data-image-precheck-review style="border-color:var(--color-warn); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bild-Sichtung: Vorab-Check hat etwas gefunden') }}</span>
<span class="badge warn">{{ __('wartet auf Entscheidung') }}</span>
</div>
<div class="p-5 space-y-3 max-w-[760px]">
<p class="m-0 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Das Bild ist bis zur Entscheidung zurückgehalten; die Meldung läuft normal weiter. Freigabe macht das Bild sichtbar, Ablehnung entfernt es. Beides wird mit Vermerk protokolliert.') }}
</p>
<ul class="m-0 list-disc pl-5 text-[13px]">
@foreach (($adminTitleImage->precheck['evaluation']['triggers'] ?? []) as $precheckTrigger)
<li>
{{ ImagePrecheckCategory::tryFrom($precheckTrigger['category'] ?? '')?->label() ?? $precheckTrigger['category'] }}
<span class="text-[color:var(--color-ink-4)]">({{ number_format((float) ($precheckTrigger['confidence'] ?? 0) * 100, 0) }} %)</span>
@if (! empty($precheckTrigger['note']))
— {{ $precheckTrigger['note'] }}
@endif
</li>
@endforeach
</ul>
<flux:textarea wire:model="precheckNote" :label="__('Vermerk (Pflicht)')" rows="2" />
<div class="flex flex-wrap gap-2">
<flux:button variant="primary" wire:click="approvePrecheck">{{ __('Bild freigeben') }}</flux:button>
<flux:button variant="danger" wire:click="rejectPrecheck" wire:confirm="{{ __('Bild wirklich ablehnen und entfernen?') }}">{{ __('Bild ablehnen') }}</flux:button>
<flux:button variant="ghost" size="sm" href="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id, 'purpose' => 'Bild-Sichtung nach Vorab-Check']) }}" target="_blank">
{{ __('Beweisoriginal öffnen') }}
</flux:button>
</div>
</div>
</article>
@elseif (isset($adminTitleImage) && $adminTitleImage && $adminTitleImage->precheck_reviewed_at !== null)
<p class="m-0 text-[12px] text-[color:var(--color-ink-4)]" data-image-precheck-decided>
{{ __('Bild-Sichtung am :date: :note', ['date' => $adminTitleImage->precheck_reviewed_at->format('d.m.Y H:i'), 'note' => $adminTitleImage->precheck_review_note]) }}
</p>
@endif
{{-- ============== KORREKTURWEG BILD (E.6) ============== --}}
@if (! $coverIsPlaceholder && isset($adminTitleImage) && $adminTitleImage && ! $adminTitleImage->isLegacyRules())
<article class="panel" data-image-correction>

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)