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) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-16 14:30:26 +00:00
parent c08c9e5de0
commit 0692d892e0
17 changed files with 487 additions and 47 deletions

View file

@ -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

View file

@ -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.
*