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:
parent
70e15d5df5
commit
8bb7049bdc
26 changed files with 1738 additions and 10 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue