From 8bb7049bdcbf59582f423f378d01fbd6471fbb97 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 22 Sep 2026 13:27:31 +0000 Subject: [PATCH] =?UTF-8?q?images:=20KI-Vorab-Check=20f=C3=BCr=20Titelbild?= =?UTF-8?q?er=20=E2=80=94=20Vision-Sichtung=20mit=20definiertem=20Ausgang?= =?UTF-8?q?=20je=20Befund,=20Admin-Queue,=20Protokoll=20und=20Archiv-Snaps?= =?UTF-8?q?hot=20(Bildkennzeichnung=20Block=20K)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 7 + app/Enums/ImagePrecheckCategory.php | 78 +++ app/Enums/ImagePrecheckOutcome.php | 38 ++ .../Resources/PressReleaseImageResource.php | 4 + app/Models/ImageRightsConfirmation.php | 2 + app/Models/KiAudit.php | 6 + app/Models/PressReleaseImage.php | 39 ++ app/Services/Admin/AdminSystemHealth.php | 11 + app/Services/Archive/PressReleaseArchiver.php | 3 +- .../Contracts/ImagePrecheckDriver.php | 15 + .../DeterministicImagePrecheckDriver.php | 19 + .../Drivers/OpenAiImagePrecheckDriver.php | 124 +++++ .../Precheck/ImagePrecheckEvaluation.php | 81 +++ .../Image/Precheck/ImagePrecheckManager.php | 30 ++ .../Image/Precheck/ImagePrecheckResult.php | 139 +++++ .../Precheck/ImagePrecheckReviewService.php | 74 +++ .../Image/Precheck/ImagePrecheckService.php | 200 +++++++ .../Image/Precheck/PrecheckImagePreparer.php | 62 +++ .../Images/PressReleaseImageUploader.php | 60 ++- config/images.php | 36 ++ ...k_fields_to_press_release_images_table.php | 36 ++ ...ot_to_image_rights_confirmations_table.php | 27 + .../admin/press-releases/index.blade.php | 17 +- .../admin/press-releases/show.blade.php | 81 +++ .../press-release-images-manager-v2.blade.php | 63 ++- tests/Feature/Images/ImagePrecheckTest.php | 496 ++++++++++++++++++ 26 files changed, 1738 insertions(+), 10 deletions(-) create mode 100644 app/Enums/ImagePrecheckCategory.php create mode 100644 app/Enums/ImagePrecheckOutcome.php create mode 100644 app/Services/Image/Precheck/Contracts/ImagePrecheckDriver.php create mode 100644 app/Services/Image/Precheck/Drivers/DeterministicImagePrecheckDriver.php create mode 100644 app/Services/Image/Precheck/Drivers/OpenAiImagePrecheckDriver.php create mode 100644 app/Services/Image/Precheck/ImagePrecheckEvaluation.php create mode 100644 app/Services/Image/Precheck/ImagePrecheckManager.php create mode 100644 app/Services/Image/Precheck/ImagePrecheckResult.php create mode 100644 app/Services/Image/Precheck/ImagePrecheckReviewService.php create mode 100644 app/Services/Image/Precheck/ImagePrecheckService.php create mode 100644 app/Services/Image/Precheck/PrecheckImagePreparer.php create mode 100644 database/migrations/2026_09_22_140000_add_precheck_fields_to_press_release_images_table.php create mode 100644 database/migrations/2026_09_22_140100_add_precheck_snapshot_to_image_rights_confirmations_table.php create mode 100644 tests/Feature/Images/ImagePrecheckTest.php diff --git a/.env.example b/.env.example index 27748cf1..419864bf 100644 --- a/.env.example +++ b/.env.example @@ -286,3 +286,10 @@ ARCHIVE_KEY= ARCHIVE_RETENTION_YEARS=3 # Zustimmungsgate vor jeder Einreichung (Block G) — erst mit AGB v0.7 einschalten. LEGAL_ACCEPTANCE_GATE=false + +# IMAGE_PRECHECK: KI-Vorab-Check für Titelbilder (Sichtung auf offensichtliche Probleme, Block K). +# Erst nach AVV mit dem Anbieter und DSE-Absatz einschalten. STAGE 1 = nur Stock-Wasserzeichen, 2 = alle Kategorien. +IMAGE_PRECHECK=false +IMAGE_PRECHECK_STAGE=1 +# IMAGE_PRECHECK_MODEL= +# IMAGE_PRECHECK_DOWNGRADE= diff --git a/app/Enums/ImagePrecheckCategory.php b/app/Enums/ImagePrecheckCategory.php new file mode 100644 index 00000000..21f1dd5f --- /dev/null +++ b/app/Enums/ImagePrecheckCategory.php @@ -0,0 +1,78 @@ + ImagePrecheckOutcome::Reject, + self::ScreenshotOrCollage, self::ThirdPartyLogoProminent, self::LicensePlateReadable => ImagePrecheckOutcome::Queue, + self::PersonsVisible, self::LooksAiGenerated, self::TechnicalDefect => ImagePrecheckOutcome::Hint, + }; + } + + /** Stufe 1 (DU 12.7) wertet nur das Stock-Wasserzeichen aus. */ + public function activeInStage(int $stage): bool + { + return $stage >= 2 || $this === self::StockWatermark; + } + + public function label(): string + { + return match ($this) { + self::StockWatermark => 'Wasserzeichen einer Bildagentur', + self::NudityOrViolence => 'Nacktheit oder Gewalt', + self::DocumentOrId => 'Dokument oder Ausweis', + self::ScreenshotOrCollage => 'Screenshot oder Collage', + self::ThirdPartyLogoProminent => 'Fremdes Logo prominent', + self::LicensePlateReadable => 'Kfz-Kennzeichen lesbar', + self::PersonsVisible => 'Personen erkennbar', + self::LooksAiGenerated => 'Wirkt KI-generiert', + self::TechnicalDefect => 'Technischer Mangel', + }; + } + + /** + * Satz für den Einreicher bei automatischer Ablehnung (DU 12.3: „Grund in + * einem Satz, Bild neu hochladen"). Nur für Ablehnungskategorien belegt. + */ + public function rejectionMessage(): string + { + return match ($this) { + self::StockWatermark => __('Das Bild trägt das Wasserzeichen einer Bildagentur und kann so nicht verwendet werden. Bitte laden Sie eine lizenzierte Fassung ohne Wasserzeichen hoch.'), + self::NudityOrViolence => __('Das Bild zeigt Inhalte, die wir nicht veröffentlichen (Nacktheit oder Gewalt). Bitte wählen Sie ein anderes Bild.'), + self::DocumentOrId => __('Das Bild zeigt ein Dokument oder einen Ausweis. Bitte wählen Sie ein anderes Bild.'), + default => __('Das Bild kann so nicht verwendet werden. Bitte wählen Sie ein anderes Bild.'), + }; + } + + /** Hinweis im Formular (DU 12.3), ohne Eingriff. */ + public function hintMessage(?string $note = null): string + { + return match ($this) { + self::PersonsVisible => __('Auf dem Bild sind Personen zu sehen. Mit Ihrer Bestätigung erklären Sie, dass sie mit der Veröffentlichung einverstanden sind.'), + self::LooksAiGenerated => __('Bitte prüfen Sie Ihre Angabe zur KI-Kennzeichnung.'), + self::TechnicalDefect => $note !== null && $note !== '' + ? __('Das Bild könnte technisch mangelhaft sein: :note', ['note' => $note]) + : __('Das Bild könnte technisch mangelhaft sein (zu klein, verzerrt oder Platzhalter).'), + default => $this->label(), + }; + } +} diff --git a/app/Enums/ImagePrecheckOutcome.php b/app/Enums/ImagePrecheckOutcome.php new file mode 100644 index 00000000..2eb3de5a --- /dev/null +++ b/app/Enums/ImagePrecheckOutcome.php @@ -0,0 +1,38 @@ + 'ohne Befund', + self::Hint => 'Hinweis an den Einreicher', + self::Queue => 'Sichtung durch den Admin', + self::Reject => 'automatisch abgelehnt', + self::Error => 'nicht geprüft (Fehler)', + }; + } + + public function rank(): int + { + return match ($this) { + self::Pass => 0, + self::Error => 1, + self::Hint => 2, + self::Queue => 3, + self::Reject => 4, + }; + } +} diff --git a/app/Http/Resources/PressReleaseImageResource.php b/app/Http/Resources/PressReleaseImageResource.php index 26c534bf..65c339ec 100644 --- a/app/Http/Resources/PressReleaseImageResource.php +++ b/app/Http/Resources/PressReleaseImageResource.php @@ -87,6 +87,10 @@ class PressReleaseImageResource extends JsonResource $warnings[] = 'ai_declaration_raised_by_metadata'; } + if ($this->awaitsPrecheckReview()) { + $warnings[] = 'image_under_review'; + } + return $warnings; } } diff --git a/app/Models/ImageRightsConfirmation.php b/app/Models/ImageRightsConfirmation.php index 6e881af6..a9b40e31 100644 --- a/app/Models/ImageRightsConfirmation.php +++ b/app/Models/ImageRightsConfirmation.php @@ -45,6 +45,7 @@ class ImageRightsConfirmation extends Model 'locale', 'file_sha256', 'declaration_snapshot', + 'precheck_snapshot', 'ip_hmac', 'user_agent', 'confirmed_at', @@ -55,6 +56,7 @@ class ImageRightsConfirmation extends Model return [ 'channel' => ConfirmationChannel::class, 'declaration_snapshot' => 'array', + 'precheck_snapshot' => 'array', 'confirmed_at' => 'datetime', ]; } diff --git a/app/Models/KiAudit.php b/app/Models/KiAudit.php index 66f717f0..e0e5d403 100644 --- a/app/Models/KiAudit.php +++ b/app/Models/KiAudit.php @@ -29,6 +29,12 @@ class KiAudit extends Model public const TYPE_CONTENT_SCORE = 'content_score'; + /** KI-Vorab-Check eines Bildes (Bildkennzeichnung Block K); `raw_response.image_id` ist null bei Ablehnung vor dem Speichern. */ + public const TYPE_IMAGE_PRECHECK = 'image_precheck'; + + /** Menschliche Entscheidung zur Bild-Sichtung (approved|rejected) mit Vermerk in `reason`. */ + public const TYPE_IMAGE_PRECHECK_REVIEW = 'image_precheck_review'; + protected $fillable = [ 'press_release_id', 'type', diff --git a/app/Models/PressReleaseImage.php b/app/Models/PressReleaseImage.php index 8f7c4776..75594d4b 100644 --- a/app/Models/PressReleaseImage.php +++ b/app/Models/PressReleaseImage.php @@ -6,6 +6,7 @@ use App\Enums\AiDeclarationSource; use App\Enums\AiImageDeclaration; use App\Enums\CreditSource; use App\Enums\ImageLicenseType; +use App\Enums\ImagePrecheckOutcome; use App\Enums\ImageProvenanceStatus; use Database\Factories\PressReleaseImageFactory; use Illuminate\Database\Eloquent\Builder; @@ -54,6 +55,12 @@ use Illuminate\Support\Facades\URL; * @property array|null $provenance * @property Carbon|null $provenance_checked_at * @property string|null $provenance_tool_version + * @property array|null $precheck + * @property ImagePrecheckOutcome|null $precheck_outcome + * @property Carbon|null $precheck_review_required_at + * @property Carbon|null $precheck_reviewed_at + * @property int|null $precheck_reviewed_by + * @property string|null $precheck_review_note * @property string|null $original_disk * @property string|null $original_path * @property string|null $original_sha256 @@ -111,6 +118,12 @@ class PressReleaseImage extends Model 'provenance', 'provenance_checked_at', 'provenance_tool_version', + 'precheck', + 'precheck_outcome', + 'precheck_review_required_at', + 'precheck_reviewed_at', + 'precheck_reviewed_by', + 'precheck_review_note', 'original_disk', 'original_path', 'original_sha256', @@ -148,6 +161,10 @@ class PressReleaseImage extends Model 'provenance_status' => ImageProvenanceStatus::class, 'provenance' => 'array', 'provenance_checked_at' => 'datetime', + 'precheck' => 'array', + 'precheck_outcome' => ImagePrecheckOutcome::class, + 'precheck_review_required_at' => 'datetime', + 'precheck_reviewed_at' => 'datetime', 'published_ever_at' => 'datetime', 'customer_confirmation_requested_at' => 'datetime', 'withheld_at' => 'datetime', @@ -228,6 +245,28 @@ class PressReleaseImage extends Model * Vorläufig zurückgehalten (Korrekturweg E.6): öffentlich greift der * Platzhalter, im Panel bleibt das Bild sichtbar. */ + /** + * Ausgang `queue` des Vorab-Checks (Block K): Bild ist zurückgehalten, + * bis ein Admin freigibt oder ablehnt. + */ + public function awaitsPrecheckReview(): bool + { + return $this->precheck_review_required_at !== null && $this->precheck_reviewed_at === null; + } + + public function scopeAwaitingPrecheckReview(Builder $query): Builder + { + return $query->whereNotNull('precheck_review_required_at')->whereNull('precheck_reviewed_at'); + } + + /** + * @return BelongsTo + */ + public function precheckReviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'precheck_reviewed_by'); + } + public function isWithheld(): bool { return $this->withheld_at !== null; diff --git a/app/Services/Admin/AdminSystemHealth.php b/app/Services/Admin/AdminSystemHealth.php index 9ed7e2a9..43637000 100644 --- a/app/Services/Admin/AdminSystemHealth.php +++ b/app/Services/Admin/AdminSystemHealth.php @@ -209,6 +209,17 @@ class AdminSystemHealth : $provenanceFailures.' Bild-Upload(s) mit Status „nicht prüfbar“ in den letzten 7 Tagen (Imagick/exif prüfen).', ]; + // Offene Bild-Sichtungen aus dem KI-Vorab-Check (Block K). + $pendingPrechecks = PressReleaseImage::query()->awaitingPrecheckReview()->count(); + $checks[] = [ + 'key' => 'image_precheck_queue', + 'label' => 'Bild-Sichtung', + 'status' => $pendingPrechecks === 0 ? self::StatusOk : self::StatusWarn, + 'detail' => $pendingPrechecks === 0 + ? 'Keine Titelbilder warten auf eine Sichtung.' + : $pendingPrechecks.' Titelbild(er) warten auf die Sichtung durch einen Admin (Pressemitteilungen ▸ Bild-Sichtung).', + ]; + // Ausstehende Medien-Privatisierung. $publicImages = PressReleaseImage::query()->withTrashed()->where('disk', 'public')->count(); $publicAttachments = PressReleaseAttachment::query()->withTrashed()->where('disk', 'public')->count(); diff --git a/app/Services/Archive/PressReleaseArchiver.php b/app/Services/Archive/PressReleaseArchiver.php index 8347a868..33189916 100644 --- a/app/Services/Archive/PressReleaseArchiver.php +++ b/app/Services/Archive/PressReleaseArchiver.php @@ -60,7 +60,8 @@ class PressReleaseArchiver 'id', 'press_release_id', 'path', 'variants', 'title', 'description', 'copyright', 'credit_source', 'author', 'license_url', 'source_url', 'ai_declaration', 'ai_declaration_user', 'ai_declaration_source', 'ai_declaration_locked', 'provenance_status', 'provenance', 'original_sha256', 'published_snapshot_sha256', 'rules_version', - 'published_ever_at', 'withheld_at', 'created_at', + 'published_ever_at', 'withheld_at', 'precheck', 'precheck_outcome', 'precheck_review_required_at', + 'precheck_reviewed_at', 'precheck_reviewed_by', 'precheck_review_note', 'created_at', ]), 'credit_line' => $pressRelease ? ImageCredit::for($image, $pressRelease->company)->line() : null, 'confirmations' => $image->rightsConfirmations()->orderBy('id')->get()->map(fn ($c) => $c->toArray())->all(), diff --git a/app/Services/Image/Precheck/Contracts/ImagePrecheckDriver.php b/app/Services/Image/Precheck/Contracts/ImagePrecheckDriver.php new file mode 100644 index 00000000..8a839a32 --- /dev/null +++ b/app/Services/Image/Precheck/Contracts/ImagePrecheckDriver.php @@ -0,0 +1,15 @@ + 'no model configured']); + } +} diff --git a/app/Services/Image/Precheck/Drivers/OpenAiImagePrecheckDriver.php b/app/Services/Image/Precheck/Drivers/OpenAiImagePrecheckDriver.php new file mode 100644 index 00000000..c6358fef --- /dev/null +++ b/app/Services/Image/Precheck/Drivers/OpenAiImagePrecheckDriver.php @@ -0,0 +1,124 @@ +preparer->prepare($absolutePath); + + $response = Http::withToken($apiKey) + ->connectTimeout(min(5, max(1, $timeout))) + ->timeout($timeout) + ->retry( + [200, 500], + when: static fn (Throwable $exception): bool => $exception instanceof ConnectionException + || ($exception instanceof RequestException && in_array($exception->response->status(), [429, 500, 502, 503, 504], true)), + throw: false, + ) + ->acceptJson() + ->post($config['url'] ?? 'https://api.openai.com/v1/chat/completions', [ + 'model' => $model, + 'response_format' => ['type' => 'json_object'], + 'messages' => [ + ['role' => 'system', 'content' => $this->systemPrompt()], + [ + 'role' => 'user', + 'content' => [ + ['type' => 'text', 'text' => 'Sichte dieses Bild nach den Kategorien im Schema und antworte nur mit dem JSON-Objekt.'], + [ + 'type' => 'image_url', + 'image_url' => [ + 'url' => 'data:image/jpeg;base64,'.base64_encode($prepared['bytes']), + 'detail' => 'auto', + ], + ], + ], + ], + ], + ]); + + if ($response->failed()) { + throw new RuntimeException("OpenAI-Bildsichtung fehlgeschlagen (HTTP {$response->status()})."); + } + + $content = $response->json('choices.0.message.content'); + + if (! is_string($content) || trim($content) === '') { + throw new RuntimeException('OpenAI-Bildsichtung lieferte keinen Inhalt.'); + } + + $decoded = json_decode($content, true); + + if (! is_array($decoded)) { + throw new RuntimeException('OpenAI-Bildsichtung lieferte kein JSON-Objekt.'); + } + + $missing = array_filter(ImagePrecheckCategory::cases(), fn (ImagePrecheckCategory $c): bool => ! array_key_exists($c->value, $decoded)); + + if (count($missing) > 0) { + throw new RuntimeException('OpenAI-Bildsichtung lieferte ein unvollständiges Schema.'); + } + + return ImagePrecheckResult::ok($decoded, 'openai', $model, null, [ + 'prompt_version' => (string) config('images.precheck.prompt_version'), + 'sent_size' => [$prepared['width'], $prepared['height']], + 'usage' => $response->json('usage'), + ]); + } + + private function systemPrompt(): string + { + $keys = implode(', ', array_map(fn (ImagePrecheckCategory $c): string => $c->value, ImagePrecheckCategory::cases())); + + return << $triggers + */ + public function __construct( + public ImagePrecheckOutcome $outcome, + public array $triggers, + public int $stage, + ) {} + + /** + * @return array + */ + public function triggersFor(ImagePrecheckOutcome $outcome): array + { + return array_values(array_filter($this->triggers, fn (array $t): bool => $t['outcome'] === $outcome)); + } + + /** Grund in einem Satz bei automatischer Ablehnung. */ + public function rejectionMessage(): ?string + { + $first = $this->triggersFor(ImagePrecheckOutcome::Reject)[0] ?? null; + + return $first !== null ? $first['category']->rejectionMessage() : null; + } + + /** + * Hinweise fürs Formular, in der Reihenfolge der Kategorien. + * + * @return array + */ + public function hints(): array + { + return array_map( + fn (array $t): string => $t['category']->hintMessage($t['note']), + $this->triggersFor(ImagePrecheckOutcome::Hint), + ); + } + + /** Kurzfassung fürs Audit: "stock_watermark 0.92, persons_visible 0.80". */ + public function summary(): string + { + if ($this->triggers === []) { + return $this->outcome === ImagePrecheckOutcome::Error ? 'nicht geprüft' : 'ohne Befund'; + } + + return implode(', ', array_map( + fn (array $t): string => sprintf('%s %.2f → %s', $t['category']->value, $t['confidence'], $t['outcome']->value), + $this->triggers, + )); + } + + /** + * @return array{outcome: string, stage: int, triggers: array} + */ + public function toArray(): array + { + return [ + 'outcome' => $this->outcome->value, + 'stage' => $this->stage, + 'triggers' => array_map(fn (array $t): array => [ + 'category' => $t['category']->value, + 'confidence' => $t['confidence'], + 'outcome' => $t['outcome']->value, + 'note' => $t['note'], + ], $this->triggers), + ]; + } +} diff --git a/app/Services/Image/Precheck/ImagePrecheckManager.php b/app/Services/Image/Precheck/ImagePrecheckManager.php new file mode 100644 index 00000000..d3e7fd11 --- /dev/null +++ b/app/Services/Image/Precheck/ImagePrecheckManager.php @@ -0,0 +1,30 @@ +config->get('images.precheck.provider') ?: 'deterministic'); + } + + public function createDeterministicDriver(): ImagePrecheckDriver + { + return $this->container->make(DeterministicImagePrecheckDriver::class); + } + + public function createOpenaiDriver(): ImagePrecheckDriver + { + return $this->container->make(OpenAiImagePrecheckDriver::class); + } +} diff --git a/app/Services/Image/Precheck/ImagePrecheckResult.php b/app/Services/Image/Precheck/ImagePrecheckResult.php new file mode 100644 index 00000000..ce917e87 --- /dev/null +++ b/app/Services/Image/Precheck/ImagePrecheckResult.php @@ -0,0 +1,139 @@ + $categories + * @param array $raw + */ + public function __construct( + public string $status, + public array $categories, + public string $provider, + public ?string $model, + public ?string $fileSha256, + public string $checkedAt, + public ?string $errorMessage = null, + public array $raw = [], + ) {} + + /** + * @param array $categories + * @param array $raw + */ + public static function ok(array $categories, string $provider, ?string $model, ?string $fileSha256, array $raw = []): self + { + return new self('ok', self::normalize($categories), $provider, $model, $fileSha256, now()->toIso8601String(), null, $raw); + } + + public static function error(string $message, string $provider, ?string $model, ?string $fileSha256): self + { + return new self('error', [], $provider, $model, $fileSha256, now()->toIso8601String(), $message); + } + + public function isError(): bool + { + return $this->status !== 'ok'; + } + + public function confidence(ImagePrecheckCategory $category): float + { + $entry = $this->categories[$category->value] ?? null; + + if ($entry === null || ! $entry['hit']) { + return 0.0; + } + + return $entry['confidence']; + } + + public function note(ImagePrecheckCategory $category): ?string + { + return $this->categories[$category->value]['note'] ?? null; + } + + public function withFileSha256(string $sha256): self + { + return new self($this->status, $this->categories, $this->provider, $this->model, $sha256, $this->checkedAt, $this->errorMessage, $this->raw); + } + + /** + * @return array{status: string, categories: array, provider: string, model: string|null, file_sha256: string|null, checked_at: string, error: string|null} + */ + public function toArray(): array + { + return [ + 'status' => $this->status, + 'categories' => $this->categories, + 'provider' => $this->provider, + 'model' => $this->model, + 'file_sha256' => $this->fileSha256, + 'checked_at' => $this->checkedAt, + 'error' => $this->errorMessage, + ]; + } + + /** + * Gegenstück zu toArray() für den Komponentenzustand (Hash-Bindung + * zwischen Vorprüfung und Speichern). + * + * @param array $data + */ + public static function fromArray(array $data): ?self + { + if (! isset($data['status'], $data['provider'], $data['checked_at']) || ! is_string($data['status'])) { + return null; + } + + return new self( + $data['status'], + self::normalize(is_array($data['categories'] ?? null) ? $data['categories'] : []), + (string) $data['provider'], + isset($data['model']) ? (string) $data['model'] : null, + isset($data['file_sha256']) ? (string) $data['file_sha256'] : null, + (string) $data['checked_at'], + isset($data['error']) ? (string) $data['error'] : null, + ); + } + + /** + * Nur bekannte Kategorien, Konfidenz auf 0…1 begrenzt, Notiz gekürzt. + * + * @param array $categories + * @return array + */ + private static function normalize(array $categories): array + { + $normalized = []; + + foreach (ImagePrecheckCategory::cases() as $category) { + $entry = $categories[$category->value] ?? null; + + if (! is_array($entry)) { + continue; + } + + $confidence = isset($entry['confidence']) && is_numeric($entry['confidence']) ? (float) $entry['confidence'] : 0.0; + $hit = (bool) ($entry['hit'] ?? false); + $note = isset($entry['note']) && is_string($entry['note']) && trim($entry['note']) !== '' ? mb_substr(trim($entry['note']), 0, 200) : null; + + $normalized[$category->value] = [ + 'hit' => $hit, + 'confidence' => max(0.0, min(1.0, round($confidence, 3))), + 'note' => $note, + ]; + } + + return $normalized; + } +} diff --git a/app/Services/Image/Precheck/ImagePrecheckReviewService.php b/app/Services/Image/Precheck/ImagePrecheckReviewService.php new file mode 100644 index 00000000..6226864b --- /dev/null +++ b/app/Services/Image/Precheck/ImagePrecheckReviewService.php @@ -0,0 +1,74 @@ +forceFill([ + 'precheck_reviewed_at' => now(), + 'precheck_reviewed_by' => $admin->getKey(), + 'precheck_review_note' => $note, + 'withheld_at' => null, + ])->save(); + + $this->audit($image, 'approved', $note); + }); + + if ($image->pressRelease?->published_at !== null) { + WebCaches::forgetPublicPages(); + } + } + + public function reject(PressReleaseImage $image, User $admin, string $note): void + { + DB::transaction(function () use ($image, $admin, $note): void { + $image->forceFill([ + 'precheck_reviewed_at' => now(), + 'precheck_reviewed_by' => $admin->getKey(), + 'precheck_review_note' => $note, + ])->save(); + + $this->audit($image, 'rejected', $note); + }); + + $this->remover->remove($image, $admin, ArchiveEvent::ImageRemoved, ArchiveReasonCategory::OwnDecision); + } + + private function audit(PressReleaseImage $image, string $decision, string $note): void + { + KiAudit::query()->create([ + 'press_release_id' => $image->press_release_id, + 'type' => KiAudit::TYPE_IMAGE_PRECHECK_REVIEW, + 'provider' => 'admin', + 'model' => null, + 'result' => $decision, + 'reason' => $note, + 'raw_response' => [ + 'image_id' => $image->getKey(), + 'reviewed_by' => $image->precheck_reviewed_by, + 'precheck' => $image->precheck, + ], + 'created_at' => now(), + ]); + } +} diff --git a/app/Services/Image/Precheck/ImagePrecheckService.php b/app/Services/Image/Precheck/ImagePrecheckService.php new file mode 100644 index 00000000..6520e703 --- /dev/null +++ b/app/Services/Image/Precheck/ImagePrecheckService.php @@ -0,0 +1,200 @@ +enabled()) { + return null; + } + + $provider = (string) config('images.precheck.provider', 'deterministic'); + + try { + return $this->manager->driver($provider)->check($absolutePath)->withFileSha256($fileSha256); + } catch (Throwable $exception) { + Log::channel('ki')->warning('KI-Vorab-Check für ein Bild fehlgeschlagen; das Bild wird durchgelassen.', [ + 'provider' => $provider, + 'error' => $exception->getMessage(), + ]); + + return ImagePrecheckResult::error( + mb_substr($exception->getMessage(), 0, 200), + $provider, + config('images.precheck.model') ?: null, + $fileSha256, + ); + } + } + + /** + * Vorhandenes Ergebnis wiederverwenden, wenn es zur Datei passt + * (Hash-Bindung zwischen Vorprüfung und Speichern), sonst neu rechnen. + */ + public function resolve(string $absolutePath, string $fileSha256, ?ImagePrecheckResult $existing): ?ImagePrecheckResult + { + if (! $this->enabled()) { + return null; + } + + if ($existing !== null && $existing->fileSha256 !== null && hash_equals($existing->fileSha256, $fileSha256)) { + return $existing; + } + + return $this->run($absolutePath, $fileSha256); + } + + /** + * Ausgang nach DU 12.3. Stufe 1 wertet nur `stock_watermark`; die + * Kundenangabe entscheidet nur darüber, ob „wirkt KI-generiert" als + * Hinweis erscheint. + */ + public function evaluate(ImagePrecheckResult $result, ?AiImageDeclaration $userChoice): ImagePrecheckEvaluation + { + $stage = $this->stage(); + + if ($result->isError()) { + return new ImagePrecheckEvaluation(ImagePrecheckOutcome::Error, [], $stage); + } + + $reject = (float) config('images.precheck.reject_threshold', 0.85); + $queue = (float) config('images.precheck.queue_threshold', 0.6); + $downgraded = (array) config('images.precheck.downgrade_to_queue', []); + + $triggers = []; + + foreach (ImagePrecheckCategory::cases() as $category) { + if (! $category->activeInStage($stage)) { + continue; + } + + $confidence = $result->confidence($category); + $default = $category->defaultOutcome(); + + if ($default === ImagePrecheckOutcome::Reject && in_array($category->value, $downgraded, true)) { + $default = ImagePrecheckOutcome::Queue; + } + + $outcome = match ($default) { + ImagePrecheckOutcome::Reject => $confidence >= $reject + ? ImagePrecheckOutcome::Reject + : ($confidence >= $queue ? ImagePrecheckOutcome::Queue : null), + ImagePrecheckOutcome::Queue => $confidence >= $queue ? ImagePrecheckOutcome::Queue : null, + ImagePrecheckOutcome::Hint => $this->hintApplies($category, $confidence, $queue, $userChoice) ? ImagePrecheckOutcome::Hint : null, + default => null, + }; + + if ($outcome === null) { + continue; + } + + $triggers[] = [ + 'category' => $category, + 'confidence' => $confidence, + 'outcome' => $outcome, + 'note' => $category === ImagePrecheckCategory::TechnicalDefect ? $result->note($category) : null, + ]; + } + + $overall = ImagePrecheckOutcome::Pass; + + foreach ($triggers as $trigger) { + if ($trigger['outcome']->rank() > $overall->rank()) { + $overall = $trigger['outcome']; + } + } + + return new ImagePrecheckEvaluation($overall, $triggers, $stage); + } + + /** + * Ablehnung vor dem Speichern (DU 12.3): kein Bild, aber ein Eintrag im + * unveränderlichen KI-Protokoll an der Meldung — der Beleg eigener + * Sorgfalt, ohne die Datei. + */ + public function auditRejected(PressRelease $pressRelease, ImagePrecheckResult $result, ImagePrecheckEvaluation $evaluation): KiAudit + { + return $this->audit($pressRelease->getKey(), null, $result, $evaluation); + } + + public function auditStored(PressReleaseImage $image, ImagePrecheckResult $result, ImagePrecheckEvaluation $evaluation): KiAudit + { + return $this->audit((int) $image->press_release_id, (int) $image->getKey(), $result, $evaluation); + } + + /** + * Wert für die Spalte `precheck` am Bild und den Snapshot im + * Bestätigungsprotokoll: Rohbefund plus Auswertung. + * + * @return array + */ + public function storedPayload(ImagePrecheckResult $result, ImagePrecheckEvaluation $evaluation): array + { + return $result->toArray() + ['evaluation' => $evaluation->toArray(), 'prompt_version' => $result->raw['prompt_version'] ?? config('images.precheck.prompt_version')]; + } + + private function hintApplies(ImagePrecheckCategory $category, float $confidence, float $queueThreshold, ?AiImageDeclaration $userChoice): bool + { + if ($confidence < $queueThreshold) { + return false; + } + + if ($category === ImagePrecheckCategory::LooksAiGenerated) { + return $userChoice === null || $userChoice === AiImageDeclaration::Original; + } + + return true; + } + + private function audit(int $pressReleaseId, ?int $imageId, ImagePrecheckResult $result, ImagePrecheckEvaluation $evaluation): KiAudit + { + return KiAudit::query()->create([ + 'press_release_id' => $pressReleaseId, + 'type' => KiAudit::TYPE_IMAGE_PRECHECK, + 'provider' => $result->provider, + 'model' => $result->model, + 'result' => $evaluation->outcome->value, + 'reason' => $evaluation->summary(), + 'raw_response' => $this->storedPayload($result, $evaluation) + ['image_id' => $imageId], + 'created_at' => now(), + ]); + } +} diff --git a/app/Services/Image/Precheck/PrecheckImagePreparer.php b/app/Services/Image/Precheck/PrecheckImagePreparer.php new file mode 100644 index 00000000..c0fd9ca5 --- /dev/null +++ b/app/Services/Image/Precheck/PrecheckImagePreparer.php @@ -0,0 +1,62 @@ +readImage($absolutePath); + $imagick->setIteratorIndex(0); + + $imagick->autoOrient(); + + $imagick->setImageBackgroundColor('white'); + $imagick->setImageAlphaChannel(Imagick::ALPHACHANNEL_REMOVE); + $imagick = $imagick->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN); + $imagick->stripImage(); + + $width = $imagick->getImageWidth(); + $height = $imagick->getImageHeight(); + + if (max($width, $height) > $maxEdge) { + $imagick->resizeImage($maxEdge, $maxEdge, Imagick::FILTER_LANCZOS, 1, true); + $width = $imagick->getImageWidth(); + $height = $imagick->getImageHeight(); + } + + $imagick->setImageFormat('jpeg'); + $imagick->setImageCompressionQuality($quality); + $bytes = $imagick->getImageBlob(); + } finally { + $imagick->clear(); + } + + if ($bytes === '') { + throw new RuntimeException('Das Bild konnte nicht für die Sichtung vorbereitet werden.'); + } + + return ['bytes' => $bytes, 'width' => $width, 'height' => $height]; + } +} diff --git a/app/Services/PressRelease/Images/PressReleaseImageUploader.php b/app/Services/PressRelease/Images/PressReleaseImageUploader.php index 488f104d..e93ace4e 100644 --- a/app/Services/PressRelease/Images/PressReleaseImageUploader.php +++ b/app/Services/PressRelease/Images/PressReleaseImageUploader.php @@ -5,6 +5,7 @@ namespace App\Services\PressRelease\Images; use App\Enums\AiDeclarationSource; use App\Enums\AiImageDeclaration; use App\Enums\CreditSource; +use App\Enums\ImagePrecheckOutcome; use App\Models\ImageRightsConfirmation; use App\Models\LegalText; use App\Models\PressRelease; @@ -13,6 +14,9 @@ use App\Scopes\PortalScope; use App\Services\Image\ImageProvenanceInspector; use App\Services\Image\ImageRightsTextRenderer; use App\Services\Image\ImageService; +use App\Services\Image\Precheck\ImagePrecheckEvaluation; +use App\Services\Image\Precheck\ImagePrecheckResult; +use App\Services\Image\Precheck\ImagePrecheckService; use App\Services\Image\ProvenanceResult; use App\Support\ImageCredit; use App\Support\WebCaches; @@ -29,7 +33,8 @@ use Throwable; * * 1. Grenzen prüfen (E-B6), Bytes einmal lesen, SHA-256 bilden. * 2. Beweisoriginal auf den privaten Originalpfad schreiben (E9, F05). - * 3. Herkunftsprüfung auf dieselben Bytes (A.5). + * 3. Herkunftsprüfung auf dieselben Bytes (A.5); KI-Vorab-Check (Block K) + * davor auf den Quellbytes — eine Ablehnung speichert nichts. * 4. Derivate über ImageService; bei Fehler alles zurückrollen. * 5. Effektive Kennzeichnung bestimmen: Kundenangabe und geprüfter Befund * getrennt; in Phase 1 belegt ein ungesicherter Befund nur vor. @@ -46,6 +51,7 @@ class PressReleaseImageUploader private readonly ImageService $imageService, private readonly ImageProvenanceInspector $inspector, private readonly ImageRightsTextRenderer $textRenderer, + private readonly ImagePrecheckService $precheck, ) {} /** @@ -60,6 +66,25 @@ class PressReleaseImageUploader return $this->inspector->inspect($upload->getRealPath()); } + /** + * KI-Vorab-Check nach der Dateiauswahl (Block K, DU 12.3). Liefert null, + * wenn der Check aus ist. Eine Ablehnung wirft sofort mit dem Grund in + * einem Satz und wird an der PM protokolliert; alles andere kommt als + * Ergebnis zurück, das `store()` über den Hash wiederverwendet. + */ + public function precheck(UploadedFile $upload, PressRelease $pressRelease): ?ImagePrecheckResult + { + $result = $this->precheck->run($upload->getRealPath(), (string) hash_file('sha256', $upload->getRealPath())); + + if ($result === null) { + return null; + } + + $this->assertNotRejected($pressRelease, $result, null); + + return $result; + } + public function store( UploadedFile $upload, PressRelease $pressRelease, @@ -67,6 +92,7 @@ class PressReleaseImageUploader ImageUploadActor $actor, ?string $expectedSha256 = null, ?string $agbUrl = null, + ?ImagePrecheckResult $precheck = null, ): PressReleaseImage { $this->assertWithinLimits($upload); @@ -81,6 +107,10 @@ class PressReleaseImageUploader $legalText = $this->resolveLegalText($submission, $actor); + // Vorab-Check vor jedem Schreiben (K.3): Ablehnung → nichts gespeichert. + $precheck = $this->precheck->resolve($sourcePath, $sha256, $precheck); + $precheckEvaluation = $precheck !== null ? $this->assertNotRejected($pressRelease, $precheck, $submission->aiDeclaration) : null; + $originalsDisk = (string) config('images.originals_disk', 'local'); $originalPath = sprintf( '%s/%d/%s.%s', @@ -120,7 +150,7 @@ class PressReleaseImageUploader ogCaption: ImageCredit::compose($submission->copyright, $effective['declaration']?->publicLabel()), ); - $image = DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl, $effective): PressReleaseImage { + $image = DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl, $effective, $precheck, $precheckEvaluation): PressReleaseImage { $locked = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereKey($pressRelease->getKey()) @@ -150,6 +180,10 @@ class PressReleaseImageUploader 'provenance' => $provenance->toArray() + ($effective['deviated'] ? ['deviated_from_prefill' => true] : []), 'provenance_checked_at' => now(), 'provenance_tool_version' => $provenance->toolVersion, + 'precheck' => $precheckEvaluation !== null ? $this->precheck->storedPayload($precheck, $precheckEvaluation) : null, + 'precheck_outcome' => $precheckEvaluation?->outcome, + 'precheck_review_required_at' => $precheckEvaluation?->outcome === ImagePrecheckOutcome::Queue ? now() : null, + 'withheld_at' => $precheckEvaluation?->outcome === ImagePrecheckOutcome::Queue ? now() : null, 'original_disk' => $originalsDisk, 'original_path' => $originalPath, 'original_sha256' => $sha256, @@ -166,6 +200,10 @@ class PressReleaseImageUploader 'mime' => $stored['mime'], ]); + if ($precheckEvaluation !== null) { + $this->precheck->auditStored($image, $precheck, $precheckEvaluation); + } + if ($actor->confirmsImmediately() && $legalText !== null) { $this->recordConfirmation($image, $locked, $actor, $legalText, $agbUrl); } @@ -265,6 +303,23 @@ class PressReleaseImageUploader ]; } + /** + * Auswertung des Vorab-Checks (K.2). Die Kundenangabe fließt nur in den + * Hinweis „wirkt KI-generiert" ein; die Ablehnung hängt allein am Befund. + */ + private function assertNotRejected(PressRelease $pressRelease, ImagePrecheckResult $result, ?AiImageDeclaration $userChoice): ImagePrecheckEvaluation + { + $evaluation = $this->precheck->evaluate($result, $userChoice); + + if ($evaluation->outcome === ImagePrecheckOutcome::Reject) { + $this->precheck->auditRejected($pressRelease, $result, $evaluation); + + throw new ImageUploadException((string) $evaluation->rejectionMessage()); + } + + return $evaluation; + } + private function creditSource(ImageSubmission $submission, ImageUploadActor $actor): CreditSource { if ($submission->creditFromSuggestion) { @@ -330,6 +385,7 @@ class PressReleaseImageUploader 'provenance_status' => $image->provenance_status?->value, 'provenance' => $image->provenance, ], + 'precheck_snapshot' => $image->precheck, 'ip_hmac' => ImageRightsConfirmation::hashIp($actor->ip), 'user_agent' => $actor->userAgent !== null ? Str::limit($actor->userAgent, 250, '') : null, 'confirmed_at' => now(), diff --git a/config/images.php b/config/images.php index 66f51322..720e697d 100644 --- a/config/images.php +++ b/config/images.php @@ -80,4 +80,40 @@ return [ 'originals_directory' => 'press-release-originals', 'published_snapshot_directory' => 'press-release-published', + /* + |-------------------------------------------------------------------------- + | KI-Vorab-Check für Bilder (Bildkennzeichnung Block K, DU Abschnitt 12) + |-------------------------------------------------------------------------- + | + | Sichtung jedes neuen Titelbilds auf offensichtliche Probleme durch ein + | Vision-Modell. Keine Rechteprüfung; öffentlich bleibt es „automatisierte + | Prüfung". Drei Regeln: jeder Befund hat einen definierten Ausgang, der + | Check setzt nie die KI-Kennzeichnung, Fehler lassen durch. + | + | enabled: Standard aus. Einschalten erst nach AVV mit dem Anbieter und + | DSE-Absatz (K.6). + | stage: 1 = nur Stock-Wasserzeichen wird ausgewertet (alle Kategorien + | werden abgefragt und protokolliert), 2 = alle Kategorien. + | provider/model/timeout: wie die Text-Prüfstrecke (services.openai). + | reject_threshold / queue_threshold: Konfidenz, ab der die Kategorie + | ihren Ausgang auslöst (DU 12.4: 0,85 / 0,6). + | downgrade_to_queue: Ablehnungskategorien, die bei gehäuften Fehlurteilen + | nur noch in die Queue gehen (DU 12.8), kommagetrennt. + | + */ + + 'precheck' => [ + 'enabled' => (bool) env('IMAGE_PRECHECK', false), + 'stage' => (int) env('IMAGE_PRECHECK_STAGE', 1), + 'provider' => env('IMAGE_PRECHECK_PROVIDER', 'openai'), + 'model' => env('IMAGE_PRECHECK_MODEL'), + 'timeout' => (int) env('IMAGE_PRECHECK_TIMEOUT', 20), + 'reject_threshold' => (float) env('IMAGE_PRECHECK_REJECT_THRESHOLD', 0.85), + 'queue_threshold' => (float) env('IMAGE_PRECHECK_QUEUE_THRESHOLD', 0.6), + 'downgrade_to_queue' => array_values(array_filter(array_map('trim', explode(',', (string) env('IMAGE_PRECHECK_DOWNGRADE', ''))))), + 'max_edge' => 1024, + 'jpeg_quality' => 82, + 'prompt_version' => 'img-precheck-2026-09', + ], + ]; diff --git a/database/migrations/2026_09_22_140000_add_precheck_fields_to_press_release_images_table.php b/database/migrations/2026_09_22_140000_add_precheck_fields_to_press_release_images_table.php new file mode 100644 index 00000000..975dbb50 --- /dev/null +++ b/database/migrations/2026_09_22_140000_add_precheck_fields_to_press_release_images_table.php @@ -0,0 +1,36 @@ +json('precheck')->nullable()->after('provenance_tool_version'); + $table->string('precheck_outcome', 10)->nullable()->after('precheck'); + $table->timestamp('precheck_review_required_at')->nullable()->after('precheck_outcome'); + $table->timestamp('precheck_reviewed_at')->nullable()->after('precheck_review_required_at'); + $table->foreignId('precheck_reviewed_by')->nullable()->after('precheck_reviewed_at')->constrained('users')->nullOnDelete(); + $table->string('precheck_review_note', 1000)->nullable()->after('precheck_reviewed_by'); + + $table->index(['precheck_review_required_at', 'precheck_reviewed_at'], 'pri_precheck_review_idx'); + }); + } + + public function down(): void + { + Schema::table('press_release_images', function (Blueprint $table) { + $table->dropIndex('pri_precheck_review_idx'); + $table->dropConstrainedForeignId('precheck_reviewed_by'); + $table->dropColumn(['precheck', 'precheck_outcome', 'precheck_review_required_at', 'precheck_reviewed_at', 'precheck_review_note']); + }); + } +}; diff --git a/database/migrations/2026_09_22_140100_add_precheck_snapshot_to_image_rights_confirmations_table.php b/database/migrations/2026_09_22_140100_add_precheck_snapshot_to_image_rights_confirmations_table.php new file mode 100644 index 00000000..18abefd7 --- /dev/null +++ b/database/migrations/2026_09_22_140100_add_precheck_snapshot_to_image_rights_confirmations_table.php @@ -0,0 +1,27 @@ +json('precheck_snapshot')->nullable()->after('declaration_snapshot'); + }); + } + + public function down(): void + { + Schema::table('image_rights_confirmations', function (Blueprint $table) { + $table->dropColumn('precheck_snapshot'); + }); + } +}; diff --git a/resources/views/livewire/admin/press-releases/index.blade.php b/resources/views/livewire/admin/press-releases/index.blade.php index 16ce8862..00cf25ed 100644 --- a/resources/views/livewire/admin/press-releases/index.blade.php +++ b/resources/views/livewire/admin/press-releases/index.blade.php @@ -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') }} ($stats['manual'] ?? 0) > 0])>{{ number_format($stats['manual'] ?? 0) }} + {{-- Block K: Titelbild aus dem Vorab-Check wartet auf Sichtung. --}} +