From 13716b9c0637a87393012149ffbdc10f8256ab8e Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 16 Sep 2026 14:30:26 +0000 Subject: [PATCH] press-releases: begrenzter Pruefkreislauf mit Admin-Queue (Roadmap 1d.4, E3) Automatische Pruefung je Meldung hoechstens max_automatic_attempts (3); das letzte Rot blockt statt abzulehnen: Meldung bleibt in review, Admin sieht sie unter "Pruefung noetig" mit allen KI-Begruendungen und entscheidet (Freischalten / Mit Kommentar zurueck). Danach ist die Automatik fuer diese Meldung aus - jede Freigabe geht direkt zum Menschen. Gelb: yellow_to_manual_queue wird jetzt ausgewertet (Vorgabe false). Kundenseite: "Zur Pruefung freigeben" mit Erklaerung der drei Schritte, Versuchszaehler an der Ablehnung, Hinweis auf menschliche Pruefung. Blade: inline @php(...) vor einem @php...@endphp-Block wird als Rohblock gelesen - vier Stellen auf Blockform umgestellt. Co-Authored-By: Claude Opus 5 (1M context) --- app/Models/PressRelease.php | 21 +++ .../PressRelease/PressReleaseService.php | 65 ++++++- ...al_review_gate_to_press_releases_table.php | 43 +++++ dev/2026-09-01/roadmap-2026.md | 12 +- dev/2026-09-01/umsetzung-protokoll.md | 66 +++++++ .../press-release-submit-modal.blade.php | 27 ++- .../admin/press-releases/index.blade.php | 21 ++- .../admin/press-releases/show.blade.php | 42 ++++- .../customer/press-releases/create.blade.php | 6 +- .../customer/press-releases/edit.blade.php | 2 +- .../customer/press-releases/index.blade.php | 6 +- .../customer/press-releases/show.blade.php | 35 +++- tests/Feature/ManualReviewGateTest.php | 168 ++++++++++++++++++ tests/Feature/PressReleaseBookingGateTest.php | 6 +- .../PressReleaseCreateCompanyGuardTest.php | 4 +- .../PressReleasePublishModalPhase8iTest.php | 8 +- tests/Feature/PressReleaseWorkflowTest.php | 2 +- 17 files changed, 487 insertions(+), 47 deletions(-) create mode 100644 database/migrations/2026_09_16_140000_add_manual_review_gate_to_press_releases_table.php create mode 100644 tests/Feature/ManualReviewGateTest.php diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index b8354258..e0186fd2 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -82,6 +82,8 @@ class PressRelease extends Model 'status', 'classification', 'classified_at', + 'auto_check_attempts', + 'manual_review_required_at', 'content_score', 'content_tier', 'scored_at', @@ -110,6 +112,8 @@ class PressRelease extends Model 'status' => PressReleaseStatus::class, 'classification' => PressReleaseClassification::class, 'classified_at' => 'datetime', + 'auto_check_attempts' => 'integer', + 'manual_review_required_at' => 'datetime', 'content_score' => 'integer', 'content_tier' => PressReleaseContentTier::class, 'scored_at' => 'datetime', @@ -529,6 +533,23 @@ class PressRelease extends Model return $this->gone_at !== null; } + /** + * Automatik erschöpft (E3): Für diese Meldung entscheidet nur noch ein + * Mensch — jede Freigabe geht ohne KI-Lauf in die Admin-Queue. + */ + public function requiresManualReview(): bool + { + return $this->manual_review_required_at !== null; + } + + /** + * Wartet geblockt auf die menschliche Entscheidung. + */ + public function isAwaitingManualReview(): bool + { + return $this->status === PressReleaseStatus::Review && $this->requiresManualReview(); + } + /** * Instanz-Pendant zu scopeVisibleInArchive(): wertet die öffentliche * Sichtbarkeit aus den bereits geladenen Attributen aus, ohne erneute diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index 887565e4..59d4ed30 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -94,20 +94,35 @@ class PressReleaseService // Zeitpunkt wird hier zur Nachweisbarkeit protokolliert. (Spalte // heißt historisch withdrawal_waiver_consented_at; die frühere // Erlöschens-Checkbox entfiel mit der B2B-Umstellung, E2.) + // Begrenzter Prüfkreislauf (E3, 16.09.2026): Ist die Automatik für + // diese Meldung erschöpft, geht die Freigabe ohne KI-Lauf direkt an + // einen Menschen. Sonst zählt dieser Lauf. + $manual = $pressRelease->requiresManualReview(); + $pressRelease->update([ 'status' => PressReleaseStatus::Review->value, 'withdrawal_waiver_consented_at' => now(), + 'auto_check_attempts' => $manual ? $pressRelease->auto_check_attempts : $pressRelease->auto_check_attempts + 1, ]); - $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, $source); + $this->logStatusChange( + $pressRelease, + $previous, + PressReleaseStatus::Review, + $manual ? 'Erneute Freigabe nach erschöpfter automatischer Prüfung — wartet auf manuelle Entscheidung.' : null, + $source, + ); // Versionierter Audit-Trail der KI-Deklaration: jeder Submit erzeugt // einen neuen, unveränderlichen Eintrag (Abs.-50-Verteidigung — // "Kunde hat am Datum X Option Y deklariert und bestätigt"). $this->logAiDeclaration($pressRelease); - // KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand - // des Ergebnisses übernimmt der Job über routeByClassification(). - ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification'); + if (! $manual) { + // KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing + // anhand des Ergebnisses übernimmt der Job über + // routeByClassification(). + ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification'); + } // Content-Score parallel berechnen (Qualität, ohne Statuswirkung). ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification'); @@ -183,14 +198,56 @@ class PressReleaseService } if ($classification === PressReleaseClassification::Red) { + $max = max(1, (int) config('scoring.classification.max_automatic_attempts', 3)); + + // E3: Nach dem letzten erlaubten Rot nicht noch einmal ablehnen, + // sondern geblockt in die Admin-Queue — ab hier entscheidet ein + // Mensch, und die Automatik ist für diese Meldung aus. + if ($pressRelease->auto_check_attempts >= $max) { + $this->holdForManualReview($pressRelease, sprintf( + 'Automatische Prüfung erschöpft (%d von %d): %s', + $pressRelease->auto_check_attempts, + $max, + $reason ?: 'Rot ohne Begründung.', + )); + + return; + } + $this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki'); return; } + // Gelb geht durch (Entscheidung 16.09.2026) — außer der Schalter für + // die manuelle Queue ist bewusst eingeschaltet. + if ($classification === PressReleaseClassification::Yellow && config('scoring.classification.yellow_to_manual_queue')) { + $this->holdForManualReview($pressRelease, 'Gelb — manuelle Prüfung laut Konfiguration.'); + + return; + } + $this->autoPublishApproved($pressRelease); } + /** + * Meldung bleibt in `review`, wird als „Prüfung nötig" markiert und + * wartet auf die Entscheidung im Admin. Kein Slot-Verbrauch, keine Mail + * an den Autor — der sieht den Stand in seinem Konto. + */ + private function holdForManualReview(PressRelease $pressRelease, string $reason): void + { + $pressRelease->update(['manual_review_required_at' => now()]); + $this->logStatusChange($pressRelease, PressReleaseStatus::Review, PressReleaseStatus::Review, $reason, 'ki'); + + $this->activityLog->record( + ActivityType::PressReleaseStatus, + sprintf('PM #%d wartet auf manuelle Prüfung: %s', $pressRelease->id, mb_substr($reason, 0, 160)), + subject: $pressRelease, + actorLabel: 'ki', + ); + } + /** * Veröffentlicht eine als Gelb oder Grün klassifizierte PM automatisch. * diff --git a/database/migrations/2026_09_16_140000_add_manual_review_gate_to_press_releases_table.php b/database/migrations/2026_09_16_140000_add_manual_review_gate_to_press_releases_table.php new file mode 100644 index 00000000..65491632 --- /dev/null +++ b/database/migrations/2026_09_16_140000_add_manual_review_gate_to_press_releases_table.php @@ -0,0 +1,43 @@ +unsignedTinyInteger('auto_check_attempts')->default(0)->after('classified_at'); + } + + if (! Schema::hasColumn('press_releases', 'manual_review_required_at')) { + $table->timestamp('manual_review_required_at')->nullable()->after('auto_check_attempts'); + } + }); + } + + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table) { + $table->dropColumn(array_filter([ + Schema::hasColumn('press_releases', 'auto_check_attempts') ? 'auto_check_attempts' : null, + Schema::hasColumn('press_releases', 'manual_review_required_at') ? 'manual_review_required_at' : null, + ])); + }); + } +}; diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index 8bcef313..4c0f6027 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -144,12 +144,12 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die | Nr. | Arbeitspaket | Aufwand | Abhängigkeit | |---|---|---|---| -| 1d.1 | Hilfeseite ohne kommerziellen Anteil: was eine gute Pressemitteilung ausmacht, wie geprüft wird, was sich verbessern lässt, Abschnitt zu Pressebildern | S | keine | -| 1d.2 | Textbaustein in die Ablehnungsmail, Verlinkung auf 1d.1 | S | 1d.1 | +| ~~1d.1~~ | Hilfeseite ohne kommerziellen Anteil — **gebaut 16.09.2026**: `/{edition}/gute-pressemitteilung` (DE/EN), Anlass, Aufbau, Sprache, Prüfablauf, sieben Qualitätsdimensionen offen, Pressebilder; Sitemap + Hilfe-Center | S | keine | +| ~~1d.2~~ | Textbaustein in die Ablehnungsmail — **16.09.2026**: Link auf dem Portal der Meldung, Herkunft `mail_reject`, nichts Kommerzielles (E6) | S | 1d.1 | | 1d.3 | Abmeldemöglichkeit für Benachrichtigungen, Status in der Suppression-Logik geführt | S | Bounce-Suppression live | -| 1d.4 | Prüfwerkzeug: eingefügter Text wird bewertet, Ausgabe als benannte Stufe mit konkreten Hebeln | M | 1d.1 | -| 1d.5 | Bewertung einer bestehenden Meldung über Magic Link | S | 1d.4, 1b.2 | -| 1d.6 | Partnerbereich am Seitenende, vorerst als "BALD" ohne Namen und Logos | S | 1d.1 | +| ~~1d.4~~ | Prüfwerkzeug — **E3 entschieden 16.09.2026: kein eigenständiges Werkzeug.** Prüfung nur beim Freigeben, Ergebnis am Entwurf; „Zur Prüfung freigeben" mit Erklärung, was danach passiert; automatische Prüfung je Meldung `max_automatic_attempts` (3), danach geblockt in die Admin-Queue („Prüfung nötig"), Freischalten oder Kommentar, Automatik für die Meldung ab dann aus | M | 1d.1 | +| ~~1d.5~~ | Bewertung einer bestehenden Meldung über Magic Link — **mit 1c.2 erledigt**: der Beansprucher sieht Ablehnungsgrund, KI-Stufe und Content-Score auf der Meldungsseite | S | 1d.4, 1b.2 | +| ~~1d.6~~ | Partnerbereich am Seitenende als BALD — **16.09.2026** am Ende von 1d.1 | S | 1d.1 | **Offenlegungsgrenze** @@ -233,7 +233,7 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig. |---|---|---| | ~~E1~~ | **Entschieden 11.09.2026: 3** (drei Live-Läufe, siehe `basiswerte-block0.md` und `umsetzung-protokoll.md` Teil 3). Konfiguriert in `config/newsroom.php`. | 1a.2 | | ~~E2~~ | **Empfehlung 01.09.2026: Rohzahlen** — kein Bodensatz, Verteilung symmetrisch, Einordnung gegen den Schnitt wäre wegen der Wachstumskurve falsch (siehe `basiswerte-block0.md`). | 1c.1 | -| E3 | Anzahl kostenloser Prüfläufe und Preis der weiteren in Credits | 1d.4 | +| ~~E3~~ | **Entschieden 16.09.2026: keine Credits.** Prüfung ist Teil der Freigabe, nicht ein Werkzeug; N automatische Läufe je Meldung (einstellbar, 3), danach Mensch, Automatik für die Meldung aus. Gelb geht weiter automatisch online, `yellow_to_manual_queue` bleibt als Schalter (Vorgabe aus). | 1d.4 | | ~~E4~~ | **Entschieden 15.09.2026: der Magic-Link-Hinweis, am Ende der Mail, als Hinweis, nicht als Angebot.** Umgesetzt in 1b.4. Der Link auf die Infoseite (1d.2) bekommt damit keinen Platz in der Veröffentlichungsmail — ein Element pro Mail. | 1b.4, 1d.2 | | E5 | Werden die bestehenden Ratgeberseiten konsolidiert oder ergänzt | 2.5 | | E6 | Bleibt die Ablehnungsstrecke dauerhaft frei von kommerziellen Angeboten | 1d.1, 1d.6 | diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 9978646c..c742c69c 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -1340,3 +1340,69 @@ und `ManualInvoicePdfRenderer` aus dem parallel entwickelten Projekt nullbare Werte, eine immer-falsche Negation. Nicht angefasst. Deploy: kein `migrate`, nur Code; `view:cache` neu. Kein Backfill nötig. + +--- + +# Teil 6 — Block 1d: Infoseite und Prüfkreislauf (16.09.2026) + +## Entscheidungen mit dem Nutzer + +- **E3:** keine Credits für Prüfläufe. Die Prüfung ist Teil des Freigebens; + ein eigenständiges Werkzeug zum Textreinkopieren gibt es nicht (wäre der + Weg, das Gate auszutesten). Automatische Prüfung je Meldung + `scoring.classification.max_automatic_attempts` (Vorgabe 3); danach + geblockt in die Admin-Queue; Freischalten oder Kommentar; ab dann ist die + Automatik für diese Meldung aus. +- **Gelb** geht weiter automatisch online. Der Schalter + `yellow_to_manual_queue` war im Code nie ausgewertet (1 288 gelbe + Meldungen in sechs Monaten ohne Blick); jetzt ausgewertet, Vorgabe + `false`, im Admin sichtbar (Filter „KI: Gelb" bestand schon). + +## Befund vorab + +84 KI-Ablehnungen in sechs Monaten, jede Meldung genau einmal, eine +davon später veröffentlicht — heute loopt niemand. Der Zähler schützt vor +einem Verhalten, das erst mit 1d.1/1d.2 entsteht. + +## 1d.1 / 1d.2 / 1d.6 (Commit c08c9e5) + +Hilfeseite `/{edition}/gute-pressemitteilung` (DE/EN): Anlass, Aufbau, +Sprache, Prüfablauf mit Versuchszahl aus der Konfiguration, die sieben +Dimensionen des Content-Scores offen (Pressestil, Struktur, Lesbarkeit, +Vollständigkeit, Quellen, Überschrift, Eigenständigkeit) — Gewichtung und +Schwellen nicht (Offenlegungsgrenze), Pressebilder, Partner als BALD. +Kein Preis-, Tarif- oder Credit-Link im Seiteninhalt (Test prüft den +Prosa-Block). Sitemap, Hilfe-Center. Ablehnungsmail verlinkt die Seite auf +dem Portal der Meldung mit `src=mail_reject&pr=`. + +## E3 — begrenzter Prüfkreislauf + +- Spalten `auto_check_attempts`, `manual_review_required_at` an + `press_releases` (INSTANT, keine Volltext-Indizes mehr). +- `submitForReview()`: zählt den Lauf; ist die Automatik erschöpft, geht + die Freigabe ohne `ClassifyPressRelease` direkt in `review` mit + Status-Log „wartet auf manuelle Entscheidung". Content-Score läuft weiter. +- `routeByClassification()`: Rot beim N-ten Lauf → `holdForManualReview()` + statt Ablehnung (bleibt `review`, Log „Automatische Prüfung erschöpft + (N von N): ", ActivityLog). Gelb → Schalter. Keine Mail an den + Autor für den Block — er sieht den Stand im Konto (offen: ob eine kurze + Mail „ein Mensch prüft" gewünscht ist). +- Admin ▸ Pressemitteilungen: Ansicht „Prüfung nötig" (Zähler rot, wenn > + 0), Filteroption, Badges „Prüfung nötig" / „nur manuell" in der Liste; + Detailseite zeigt alle KI-Begründungen chronologisch und beschriftet die + Aktionen „Freischalten" / „Mit Kommentar zurück" (bestehende + publish/reject-Aktionen, Kommentar geht per Ablehnungsmail an den Autor). +- Kundenseite: Button „Zur Prüfung freigeben" (statt „einreichen", überall + vereinheitlicht), Modal erklärt in drei Schritten, was nach der Freigabe + passiert; abgelehnte Meldung zeigt „Automatische Prüfung X von N genutzt" + mit Link auf 1d.1; geblockte Meldung: „wird von einem Menschen geprüft". +- Nebenbefund Blade: ein inline `@php(...)` wird bis zum nächsten + `@php … @endphp` als Rohblock gelesen — vier Stellen auf Blockform + umgestellt, eine davon Bestand (Modal). + +Tests: `ManualReviewGateTest` (5), `StaticPagesTest` (+2), +`ConversionFunnelTest` (erweitert). 1d.5 ist mit 1c.2 erledigt (der +Beansprucher sieht Ablehnungsgrund, KI-Stufe, Content-Score). + +Deploy: `migrate --force` (Millisekunden), `view:cache`, `queue:restart`. +Kein Backfill — bestehende Meldungen starten mit 0 Versuchen. diff --git a/resources/views/components/press-release-submit-modal.blade.php b/resources/views/components/press-release-submit-modal.blade.php index 99d9cfed..b0de2468 100644 --- a/resources/views/components/press-release-submit-modal.blade.php +++ b/resources/views/components/press-release-submit-modal.blade.php @@ -30,7 +30,10 @@ Bildrechte-Bestätigung — es gibt nichts zu bestätigen. Default true, damit die Checkbox im Zweifel lieber einmal zu viel erscheint. --}} -@php($bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true)) +{{-- Blockform: ein inline @php(...) würde bis zum nächsten @endphp weiter unten als Rohblock gelesen. --}} +@php + $bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true); +@endphp @if ($bookingRequired) @@ -72,16 +75,32 @@
{{ __('Veröffentlichung') }} - {{ __('Pressemitteilung zur Prüfung einreichen') }} + {{ __('Pressemitteilung zur Prüfung freigeben') }}
+ {{-- E3 (16.09.2026): unmissverständlich, dass die Freigabe nicht + „online" heißt — erst die Prüfung, dann die Veröffentlichung. --}} + @php + $maxAttempts = max(1, (int) config('scoring.classification.max_automatic_attempts', 3)); + @endphp +
+
+

{{ __('Was nach der Freigabe passiert') }}

+
    +
  1. {{ __('Der Text geht zur automatischen Inhaltsprüfung — er ist damit noch nicht online.') }}
  2. +
  3. {{ __('Grün oder Gelb: die Mitteilung wird veröffentlicht. Rot: sie kommt mit Begründung zurück, Sie können nachbessern und erneut freigeben.') }}
  4. +
  5. {{ __('Nach :max Prüfungen mit Rot sieht sich die Redaktion den Text an und entscheidet.', ['max' => $maxAttempts]) }}
  6. +
+
+
+ {{-- Rechtliche Hinweise (Platzhalter — vor Go-Live anwaltlich prüfen) --}}
{{ __('Rechtliche Hinweise') }}
-

{{ __('Mit dem Einreichen versichern Sie:') }}

+

{{ __('Mit der Freigabe versichern Sie:') }}

  • {{ __('Sie sind befugt, den Inhalt zu veröffentlichen.') }}
  • {{ __('Alle verwendeten Bilder, Logos und Zitate liegen in Ihrer Nutzungsbefugnis.') }}
  • @@ -205,7 +224,7 @@ - {{ $confirmLabel ?? __('Veröffentlichung anfordern') }} + {{ $confirmLabel ?? __('Zur Prüfung freigeben') }}
diff --git a/resources/views/livewire/admin/press-releases/index.blade.php b/resources/views/livewire/admin/press-releases/index.blade.php index dea1d5d3..16ce8862 100644 --- a/resources/views/livewire/admin/press-releases/index.blade.php +++ b/resources/views/livewire/admin/press-releases/index.blade.php @@ -307,7 +307,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten ->orWhereHas('company', fn ($q) => $q->where('name', 'like', '%'.$term.'%')); }); }) - ->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter)) + // „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)) ->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)) @@ -413,6 +415,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten ->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft', [PressReleaseStatus::Draft->value]) ->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as rejected', [PressReleaseStatus::Rejected->value]) ->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as archived', [PressReleaseStatus::Archived->value]) + ->selectRaw('SUM(CASE WHEN status = ? AND manual_review_required_at IS NOT NULL THEN 1 ELSE 0 END) as manual', [PressReleaseStatus::Review->value]) ->first(); return [ @@ -422,6 +425,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten 'draft' => (int) ($stats->draft ?? 0), 'rejected' => (int) ($stats->rejected ?? 0), 'archived' => (int) ($stats->archived ?? 0), + 'manual' => (int) ($stats->manual ?? 0), ]; }); } @@ -574,6 +578,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten @class(['view-tab', 'is-active' => $statusFilter === \App\Enums\PressReleaseStatus::Review->value])> {{ __('In Prüfung') }} {{ number_format($stats['review']) }} + {{-- E3: Automatik erschöpft — hier wartet eine menschliche Entscheidung. --}} +